diff --git a/Ports/JavaSE/build.xml b/Ports/JavaSE/build.xml index a8f6657be7..bf74815464 100644 --- a/Ports/JavaSE/build.xml +++ b/Ports/JavaSE/build.xml @@ -154,6 +154,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 96dc4124b7..f4267c7eb7 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -755,6 +755,14 @@ public static void setShowEDTViolationStacks(boolean aShowEDTViolationStacks) { static final int GAME_KEY_CODE_RIGHT = -94; private static String nativeTheme; private static Resources nativeThemeRes; + /** + * The simulatorNativeTheme value that {@link #loadSkinFile(InputStream, JFrame)} + * last applied as the native-theme override. Useful for tests that + * need to verify the "Native Theme" menu's selection actually took + * effect on simulator reload. Null when no explicit override was + * resolved (e.g. the skin's embedded theme is in use). + */ + private static String currentSimulatorNativeTheme; private static int softkeyCount = 1; private static boolean tablet; private static String DEFAULT_FONT = "Arial-plain-11"; @@ -1213,6 +1221,17 @@ public static Resources getNativeTheme() { return nativeThemeRes; } + /** + * Returns the resolved native-theme override key (e.g. + * "iOSModernTheme") that the simulator's last {@code loadSkinFile} + * applied, or null when no override was active. Used by tests to + * verify that the "Native Theme" menu's selection actually flowed + * through the simulator reload path. + */ + public static String getCurrentSimulatorNativeTheme() { + return currentSimulatorNativeTheme; + } + public boolean hasNativeTheme() { return nativeTheme != null || nativeThemeRes != null; } @@ -2950,6 +2969,7 @@ private void loadSkinFile(InputStream skin, final JFrame frm) { // Explicit "keep the skin's embedded theme". overrideTheme = null; } + currentSimulatorNativeTheme = overrideTheme; if (overrideTheme != null) { InputStream bundled = JavaSEPort.class.getResourceAsStream("/" + overrideTheme + ".res"); if (bundled != null) { @@ -5158,15 +5178,14 @@ public void actionPerformed(ActionEvent e) { } private JMenu createSkinsMenu(final JFrame frm, final JMenu menu) throws MalformedURLException { - JMenu m; + final JMenu skinMenu; if (menu == null) { - m = new JMenu("Skins"); - m.setDoubleBuffered(true); + skinMenu = new JMenu("Skins"); + skinMenu.setDoubleBuffered(true); } else { - m = menu; - m.removeAll(); + skinMenu = menu; + skinMenu.removeAll(); } - final JMenu skinMenu = m; // Top-level: file picker for a user-supplied .skin JMenuItem addSkin = new JMenuItem("Add Skin"); @@ -5191,15 +5210,25 @@ public boolean accept(File file, String string) { perfMonitor.dispose(); perfMonitor = null; } + String path = picker.getDirectory() + File.separator + file; + File picked = new File(path); + if (picked.exists()) { + // Persist immediately so the picked skin shows up + // at the top level the next time the user opens + // the menu, even if they dismiss the reload + // before loadSkinFile finishes its own + // addSkinName. + addSkinName(picked.toURI().toString()); + } String mainClass = System.getProperty("MainClass"); if (mainClass != null) { Preferences p = Preferences.userNodeForPackage(JavaSEPort.class); - p.put("skin", picker.getDirectory() + File.separator + file); + p.put("skin", path); deinitializeSync(); frm.dispose(); System.setProperty("reload.simulator", "true"); } else { - loadSkinFile(picker.getDirectory() + File.separator + file, frm); + loadSkinFile(path, frm); refreshSkin(frm); } } @@ -5208,8 +5237,9 @@ public boolean accept(File file, String string) { skinMenu.add(addSkin); // Top-level: hand off to the hosted Skin Designer for building - // a new skin from scratch. Replaces the bundled gallery; the - // pre-built skins all live behind the "Legacy Skins" submenu. + // a new skin from scratch. The legacy OTA gallery moved into + // the submenu below; this is the supported way to author new + // skins. JMenuItem designerItem = new JMenuItem("Skin Designer"); designerItem.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent ae) { @@ -5225,103 +5255,147 @@ public void actionPerformed(ActionEvent ae) { skinMenu.addSeparator(); + // One ButtonGroup spans the top-level radios and the Legacy + // Skins submenu so the visually-selected skin is unique across + // both. Without this, opening the submenu shows two separate + // selected radios. + final ButtonGroup skinGroup = new ButtonGroup(); + final Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); + final String currentSkin = pref.get("skin", System.getProperty("dskin")); + final boolean desktopSkinPref = pref.getBoolean("desktopSkin", false); + final boolean uwpDesktopSkinPref = pref.getBoolean("uwpDesktopSkin", false); + + // Partition the configured skins. The framework default and + // anything the user added via "Add Skin" stay at the top level; + // OTA downloads from the legacy codenameone.com gallery move + // into the submenu so the top level isn't cluttered with old + // device skins most users never installed. + String skinNames = pref.get("skins", DEFAULT_SKINS); + if (skinNames == null || skinNames.length() < DEFAULT_SKINS.length()) { + skinNames = DEFAULT_SKINS; + } + final List topLevelSkins = new ArrayList(); + final List otaSkins = new ArrayList(); + StringTokenizer tkn = new StringTokenizer(skinNames, ";"); + while (tkn.hasMoreTokens()) { + String entry = tkn.nextToken(); + String kind = classifySkin(entry); + if ("ota".equals(kind)) { + otaSkins.add(entry); + } else if (kind != null) { + topLevelSkins.add(entry); + } + } + + for (String entry : topLevelSkins) { + JRadioButtonMenuItem item = buildSkinRadioItem(frm, entry, currentSkin, desktopSkinPref); + skinGroup.add(item); + skinMenu.add(item); + } + + skinMenu.addSeparator(); + + // Desktop pseudo-skins flip the "desktopSkin" preference; they + // don't pick a .skin file. Modeling them as radios in the same + // group makes the active chrome mode visible at a glance. + JRadioButtonMenuItem dSkin = new JRadioButtonMenuItem("Desktop.skin", + desktopSkinPref && !uwpDesktopSkinPref); + dSkin.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent ae) { + switchDesktopSkin(frm, false); + } + }); + JRadioButtonMenuItem uwpSkin = new JRadioButtonMenuItem("UWP Desktop.skin", + desktopSkinPref && uwpDesktopSkinPref); + uwpSkin.setToolTipText("Windows 10 Desktop Skin"); + uwpSkin.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent ae) { + switchDesktopSkin(frm, true); + } + }); + skinGroup.add(dSkin); + skinGroup.add(uwpSkin); + skinMenu.add(dSkin); + skinMenu.add(uwpSkin); + + skinMenu.addSeparator(); + + // Legacy Skins submenu: OTA-downloaded skins from the + // codenameone.com gallery, plus the gallery downloader itself + // ("More...") and the "Reset Skins" action that clears + // ~/.codenameone/ back to a clean state. final JMenu legacyMenu = new JMenu("Legacy Skins"); legacyMenu.setDoubleBuffered(true); skinMenu.add(legacyMenu); - populateLegacySkinsMenu(frm, legacyMenu); + populateLegacySkinsMenu(frm, skinMenu, legacyMenu, skinGroup, otaSkins, currentSkin, desktopSkinPref); return skinMenu; } - private void populateLegacySkinsMenu(final JFrame frm, final JMenu legacyMenu) throws MalformedURLException { - legacyMenu.removeAll(); - final JMenu skinMenu = legacyMenu; - Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); - String skinNames = pref.get("skins", DEFAULT_SKINS); - if (skinNames != null) { - if (skinNames.length() < DEFAULT_SKINS.length()) { - skinNames = DEFAULT_SKINS; - } - ButtonGroup skinGroup = new ButtonGroup(); - StringTokenizer tkn = new StringTokenizer(skinNames, ";"); - while (tkn.hasMoreTokens()) { - final String current = tkn.nextToken(); - String name = current; - if (current.contains(":")) { - try { - URL u = new URL(current); - File f = new File(u.getFile()); - if (!f.exists()) { - continue; - } - name = f.getName(); - - } catch (Exception e) { - continue; - } - } else { - // remove the old builtin skins from the menu - if(current.startsWith("/") && !current.equals(DEFAULT_SKIN)) { - continue; - } - } - String d = System.getProperty("dskin"); - JRadioButtonMenuItem i = new JRadioButtonMenuItem(name, name.equals(pref.get("skin", d))); - i.addActionListener(new ActionListener() { - - public void actionPerformed(ActionEvent ae) { - if (netMonitor != null) { - netMonitor.dispose(); - netMonitor = null; - } - if (perfMonitor != null) { - perfMonitor.dispose(); - perfMonitor = null; - } - Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); - pref.putBoolean("desktopSkin", false); - String mainClass = System.getProperty("MainClass"); - if (mainClass != null) { - pref.put("skin", current); - frm.dispose(); - System.setProperty("reload.simulator", "true"); - } else { - loadSkinFile(current, frm); - refreshSkin(frm); - } - } - }); - skinGroup.add(i); - skinMenu.add(i); + /** + * Categorise a stored skin path. The "skins" preference accumulates + * a mix of: the framework default ("/iPhoneX.skin"), file:// URIs + * pointing into ~/.codenameone/ (OTA downloads), and arbitrary + * filesystem paths the user picked via "Add Skin". Stale entries + * from removed bundled skins also leak in. The kind returned drives + * which menu the entry belongs to. + * + * @return "default" for DEFAULT_SKIN, "ota" for downloads in + * ~/.codenameone/, "user" for any other resolvable filesystem skin, + * or {@code null} when the entry should be dropped. + */ + private String classifySkin(String pathOrURI) { + if (pathOrURI == null || pathOrURI.isEmpty()) { + return null; + } + File asFile = null; + if (pathOrURI.startsWith("file:") || pathOrURI.contains("://")) { + try { + asFile = new File(new URL(pathOrURI).getFile()); + } catch (Exception e) { + return null; } + } else { + asFile = new File(pathOrURI); } - JMenuItem dSkin = new JMenuItem("Desktop.skin"); - - dSkin.addActionListener(new ActionListener() { - - public void actionPerformed(ActionEvent ae) { - if (netMonitor != null) { - netMonitor.dispose(); - netMonitor = null; - } - if (perfMonitor != null) { - perfMonitor.dispose(); - perfMonitor = null; + if (asFile != null && asFile.exists()) { + File otaRoot = new File(System.getProperty("user.home"), ".codenameone"); + try { + String otaCanonical = otaRoot.getCanonicalPath() + File.separator; + if (asFile.getCanonicalPath().startsWith(otaCanonical)) { + return "ota"; } - Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); - pref.putBoolean("desktopSkin", true); - pref.putBoolean("uwpDesktopSkin", false); - String mainClass = System.getProperty("MainClass"); - if (mainClass != null) { - deinitializeSync(); - frm.dispose(); - System.setProperty("reload.simulator", "true"); - } + } catch (IOException ignored) { } - }); - JMenuItem uwpSkin = new JMenuItem("UWP Desktop.skin"); - uwpSkin.setToolTipText("Windows 10 Desktop Skin"); - uwpSkin.addActionListener(new ActionListener() { + return "user"; + } + // Doesn't resolve on the filesystem: treat as a classpath + // resource. Only the current default is kept; old bundled + // skins were removed and would error out at load time. + if (DEFAULT_SKIN.equals(pathOrURI)) { + return "default"; + } + return null; + } + private JRadioButtonMenuItem buildSkinRadioItem(final JFrame frm, final String skinPath, + final String currentSkin, final boolean desktopSkinActive) { + String name; + if (skinPath.startsWith("file:") || skinPath.contains("://")) { + try { + name = new File(new URL(skinPath).getFile()).getName(); + } catch (Exception e) { + name = skinPath; + } + } else if (skinPath.startsWith("/") && !new File(skinPath).exists()) { + // classpath resource - drop the leading slash for display + name = skinPath.substring(1); + } else { + File f = new File(skinPath); + name = f.exists() ? f.getName() : skinPath; + } + JRadioButtonMenuItem item = new JRadioButtonMenuItem(name, + !desktopSkinActive && skinPath.equals(currentSkin)); + item.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent ae) { if (netMonitor != null) { netMonitor.dispose(); @@ -5332,23 +5406,56 @@ public void actionPerformed(ActionEvent ae) { perfMonitor = null; } Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); - pref.putBoolean("desktopSkin", true); - pref.putBoolean("uwpDesktopSkin", true); + pref.putBoolean("desktopSkin", false); String mainClass = System.getProperty("MainClass"); if (mainClass != null) { - deinitializeSync(); + pref.put("skin", skinPath); frm.dispose(); System.setProperty("reload.simulator", "true"); - } + } else { + loadSkinFile(skinPath, frm); + refreshSkin(frm); + } } }); - skinMenu.addSeparator(); - skinMenu.add(dSkin); - skinMenu.add(uwpSkin); - - skinMenu.addSeparator(); + return item; + } + + private void switchDesktopSkin(JFrame frm, boolean uwp) { + if (netMonitor != null) { + netMonitor.dispose(); + netMonitor = null; + } + if (perfMonitor != null) { + perfMonitor.dispose(); + perfMonitor = null; + } + Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); + pref.putBoolean("desktopSkin", true); + pref.putBoolean("uwpDesktopSkin", uwp); + if (System.getProperty("MainClass") != null) { + deinitializeSync(); + frm.dispose(); + System.setProperty("reload.simulator", "true"); + } + } + + private void populateLegacySkinsMenu(final JFrame frm, final JMenu topLevelMenu, final JMenu legacyMenu, + final ButtonGroup skinGroup, final List otaSkins, final String currentSkin, + final boolean desktopSkinActive) throws MalformedURLException { + legacyMenu.removeAll(); + + for (String entry : otaSkins) { + JRadioButtonMenuItem item = buildSkinRadioItem(frm, entry, currentSkin, desktopSkinActive); + skinGroup.add(item); + legacyMenu.add(item); + } + if (!otaSkins.isEmpty()) { + legacyMenu.addSeparator(); + } + JMenuItem more = new JMenuItem("More..."); - skinMenu.add(more); + legacyMenu.add(more); more.addActionListener(new ActionListener() { @Override @@ -5550,7 +5657,12 @@ public void run() { downloadMessage.setVisible(false); d.setVisible(false); try { - populateLegacySkinsMenu(frm, skinMenu); + // Rebuild the whole Skins menu - newly + // downloaded entries land in the Legacy + // submenu but the shared ButtonGroup + // spans both, so a partial rebuild + // would leak the previous group. + createSkinsMenu(frm, topLevelMenu); } catch (MalformedURLException ex) { Logger.getLogger(JavaSEPort.class.getName()).log(Level.SEVERE, null, ex); } @@ -5577,15 +5689,15 @@ public void run() { } }); - skinMenu.addSeparator(); + legacyMenu.addSeparator(); JMenuItem reset = new JMenuItem("Reset Skins"); - skinMenu.add(reset); + legacyMenu.add(reset); reset.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent ae) { if(JOptionPane.showConfirmDialog(frm, - "Are you sure you want to reset skins to default?", - "Clean Storage", + "Are you sure you want to reset skins to default?", + "Clean Storage", JOptionPane.OK_CANCEL_OPTION) == JOptionPane.OK_OPTION){ Preferences pref = Preferences.userNodeForPackage(JavaSEPort.class); pref.put("skins", DEFAULT_SKINS); diff --git a/scripts/javase/lib/SimulatorModeTestApp.java b/scripts/javase/lib/SimulatorModeTestApp.java index 9a362540f4..5a691bc7b3 100644 --- a/scripts/javase/lib/SimulatorModeTestApp.java +++ b/scripts/javase/lib/SimulatorModeTestApp.java @@ -1,16 +1,27 @@ package com.codenameone.examples.javase.tests; +import com.codename1.impl.javase.JavaSEPort; import com.codename1.ui.Button; import com.codename1.ui.CN; +import com.codename1.ui.CheckBox; import com.codename1.ui.Dialog; +import com.codename1.ui.Display; import com.codename1.ui.Form; import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; import com.codename1.io.ConnectionRequest; import com.codename1.io.NetworkManager; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.Resources; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; /** * Small simulator app used by JavaSE integration tests. @@ -19,11 +30,26 @@ public class SimulatorModeTestApp { private Form current; public void init(Object context) { + Toolbar.setGlobalToolbar(true); + boolean appThemeInstalled = false; try { Resources theme = Resources.openLayered("/theme"); UIManager.getInstance().setThemeProps(theme.getTheme(theme.getThemeResourceNames()[0])); + appThemeInstalled = true; } catch (Exception ignored) { - // Fallback to default theme if test resource isn't available. + // No app theme bundled - fall through to native-theme-only + // path below so the screenshot still reflects the user's + // Native Theme menu pick. + } + if (!appThemeInstalled && Display.getInstance().hasNativeTheme()) { + // JavaSEPort.loadSkinFile has already cached the + // simulatorNativeTheme pick as nativeThemeRes, but nothing + // has pushed it into UIManager. Installing it directly + // lights up the iOS Modern / Android Material chrome in + // the captured screenshot; without this the form renders + // with the bare DefaultLookAndFeel and the screenshots + // for every theme look identical. + Display.getInstance().installNativeTheme(); } } @@ -34,14 +60,50 @@ public void start() { } String mode = System.getProperty("cn1.test.window.mode", "unknown"); Form form = new Form("JavaSE Simulator Test", new BorderLayout()); + // The Toolbar carries most of the visual identity of a native + // theme (title bar color, font, status-bar treatment), so we + // need the global Toolbar in place for the screenshot to look + // different across themes. + Toolbar tb = form.getToolbar(); + tb.setTitle("JavaSE Simulator Test"); + tb.addMaterialCommandToSideMenu("Settings", com.codename1.ui.FontImage.MATERIAL_SETTINGS, + evt -> { /* placeholder */ }); + form.add(BorderLayout.NORTH, new Label("Window mode: " + mode)); com.codename1.ui.Container body = new com.codename1.ui.Container(BoxLayout.y()); body.add(new Label("Robot validation baseline")); - body.add(new Button("Primary Action")); + Button primary = new Button("Primary Action"); + primary.setUIID("RaisedButton"); + body.add(primary); Button dialogButton = new Button("Open Dialog"); dialogButton.addActionListener(evt -> Dialog.show("Mode", "Current mode: " + mode, "OK", null)); body.add(dialogButton); + // Add a few more theme-bearing components so the screenshot + // captures more than just two flat buttons. CheckBox + TextField + // pick up theme-specific colors and metrics that diverge + // visibly between iOS Modern, Android Material and the legacy + // pair. + body.add(new CheckBox("Enable feature")); + TextField tf = new TextField("", "Text field hint", 20, TextField.ANY); + body.add(tf); + + // Native-theme verification: when the harness sets + // cn1.test.expectedNativeTheme, query JavaSEPort for the + // resource it actually loaded and emit a result line to both + // stdout and an optional sentinel file. The outer verifier reads + // either channel to assert the menu's simulatorNativeTheme + // preference was honored on simulator startup. We surface the + // result in the UI too so the screenshot of a failing case is + // self-explanatory. + String expectedNativeTheme = System.getProperty("cn1.test.expectedNativeTheme"); + if (expectedNativeTheme != null && !expectedNativeTheme.isEmpty()) { + String diagnostic = reportNativeThemeResult(expectedNativeTheme); + Label diagLabel = new Label(diagnostic); + diagLabel.setUIID("Label"); + body.add(diagLabel); + } + form.add(BorderLayout.CENTER, body); current = form; @@ -76,6 +138,78 @@ public void start() { }); } + /** + * Reports whether the active native theme matches {@code expected}. + * + *

The current Simulator path stores the user's "Native Theme" menu + * choice in the {@code simulatorNativeTheme} preference, then reloads + * the simulator. On reload, {@code JavaSEPort.loadSkinFile} reads the + * preference, loads {@code /<name>.res} from the classpath, and + * caches the resolved key. We check three things: + * + *

    + *
  1. {@code JavaSEPort.getCurrentSimulatorNativeTheme()} matches + * the expected key - this is what the simulator's loadSkinFile + * captured from the preference / build hints. A mismatch here + * means the preference wasn't honored or auto-resolution + * picked something else.
  2. + *
  3. {@code JavaSEPort.getNativeTheme()} returns non-null - the + * cached {@code Resources} is what {@code installNativeTheme} + * actually layers under the app theme. Null here means the + * .res lookup failed even though the override was set, which + * is what you'd hit if the modern themes weren't bundled.
  4. + *
  5. The expected {@code .res} is still present in the classpath + * so the test is exercising a real load path.
  6. + *
+ * + *

The string returned is what we render on the form for the + * screenshot. + */ + private String reportNativeThemeResult(String expected) { + String resolvedKey = null; + boolean nativeResLoaded = false; + boolean expectedResPresent = false; + try { + resolvedKey = JavaSEPort.getCurrentSimulatorNativeTheme(); + Resources nativeRes = JavaSEPort.getNativeTheme(); + nativeResLoaded = nativeRes != null; + InputStream is = JavaSEPort.class.getResourceAsStream("/" + expected + ".res"); + if (is != null) { + expectedResPresent = true; + try { is.close(); } catch (Exception ignored) { } + } + } catch (Exception ex) { + ex.printStackTrace(); + } + boolean pass = expected.equals(resolvedKey) && nativeResLoaded && expectedResPresent; + String result = pass ? "PASS" : "FAIL"; + String line = "[native-theme-test] result=" + result + + " expected=" + expected + + " resolvedKey=" + resolvedKey + + " nativeResLoaded=" + nativeResLoaded + + " expectedResPresent=" + expectedResPresent; + System.out.println(line); + // Optional sentinel file so harnesses that can't tail stdout in + // realtime can still read the result. The path is configured by + // the verifier; absence is fine. + String sentinel = System.getProperty("cn1.test.nativeThemeResultFile"); + if (sentinel != null && !sentinel.isEmpty()) { + try { + Path p = Paths.get(sentinel); + Path parent = p.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(p, (line + System.lineSeparator()).getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return "Native theme " + result + ": expected=" + expected + + " loaded=" + (resolvedKey != null ? resolvedKey : "(none)"); + } + public void stop() { current = CN.getCurrentForm(); } diff --git a/scripts/javase/lib/SimulatorWindowModeVerifier.java b/scripts/javase/lib/SimulatorWindowModeVerifier.java index f4b1cbd4de..5b3612dcba 100644 --- a/scripts/javase/lib/SimulatorWindowModeVerifier.java +++ b/scripts/javase/lib/SimulatorWindowModeVerifier.java @@ -33,6 +33,14 @@ public static void main(String[] args) { Path projectDir = prepareCodenameOneSettings(); Path prefsRoot = configureSimulatorPreferences(parsed, projectDir); + // Native-theme scenarios write the result line to this + // sentinel so the verifier can read it after capturing the + // screenshot. Path lives in the temp project so different + // scenario runs don't trample each other. + Path nativeThemeSentinel = parsed.nativeTheme != null + ? projectDir.resolve("native-theme-result.txt") + : null; + List cmd = new ArrayList(); String javaExec = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; cmd.add(javaExec); @@ -55,6 +63,10 @@ public static void main(String[] args) { cmd.add("-Dcn1.simulator.autoTestRecorder=true"); cmd.add("-Dcn1.simulator.autoTestRecorderRecord=true"); } + if (parsed.nativeTheme != null) { + cmd.add("-Dcn1.test.expectedNativeTheme=" + parsed.nativeTheme); + cmd.add("-Dcn1.test.nativeThemeResultFile=" + nativeThemeSentinel.toAbsolutePath()); + } if (parsed.skinPath != null && parsed.skinPath.length() > 0) { cmd.add("-Dskin=" + parsed.skinPath); cmd.add("-Ddskin=" + parsed.skinPath); @@ -67,7 +79,17 @@ public static void main(String[] args) { ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(projectDir.toFile()); pb.redirectErrorStream(true); - pb.inheritIO(); + if (parsed.nativeTheme != null) { + // Capture output to a log so we can also confirm the + // result line on stdout if the sentinel file isn't + // written for some reason. Without the redirect the + // inherited stdout would be swallowed by the JVM and + // unavailable to the assertion below. + Path logPath = projectDir.resolve("simulator-output.log"); + pb.redirectOutput(logPath.toFile()); + } else { + pb.inheritIO(); + } child = pb.start(); waitForSimulatorWarmup(Duration.ofSeconds("network-monitor".equals(parsed.scenario) ? 12 : 8)); @@ -80,7 +102,13 @@ public static void main(String[] args) { if (!ImageIO.write(image, "png", screenshotPath.toFile())) { throw new AssertionError("No PNG writer available; screenshot was not written"); } - System.out.println("[javase-verifier] screenshot=" + screenshotPath + " mode=" + parsed.mode + " scenario=" + parsed.scenario); + System.out.println("[javase-verifier] screenshot=" + screenshotPath + + " mode=" + parsed.mode + " scenario=" + parsed.scenario + + (parsed.nativeTheme != null ? " nativeTheme=" + parsed.nativeTheme : "")); + + if (parsed.nativeTheme != null) { + assertNativeThemeApplied(parsed, nativeThemeSentinel, projectDir); + } exitCode = 0; } catch (Throwable t) { t.printStackTrace(System.err); @@ -129,6 +157,40 @@ private static void validateScreenshotContent(BufferedImage image) { } } + /** + * Reads the result line written by {@code SimulatorModeTestApp} + * during simulator startup and verifies it reports a PASS. The + * sentinel file is preferred since it lands the line atomically; + * we fall back to the captured stdout log if the sentinel is + * missing (e.g. the app's init threw before the report ran). + */ + private static void assertNativeThemeApplied(Args args, Path sentinel, Path projectDir) throws Exception { + String line = null; + if (sentinel != null && Files.exists(sentinel)) { + line = new String(Files.readAllBytes(sentinel), StandardCharsets.UTF_8).trim(); + } + if (line == null || line.isEmpty()) { + Path log = projectDir.resolve("simulator-output.log"); + if (Files.exists(log)) { + for (String l : Files.readAllLines(log, StandardCharsets.UTF_8)) { + if (l.startsWith("[native-theme-test]")) { + line = l.trim(); + break; + } + } + } + } + if (line == null || line.isEmpty()) { + throw new AssertionError("Native theme test produced no result line for " + + args.nativeTheme + " (sentinel=" + sentinel + ")"); + } + System.out.println("[javase-verifier] native-theme assertion: " + line); + if (!line.contains("result=PASS")) { + throw new AssertionError("Native theme " + args.nativeTheme + + " was not loaded by the simulator: " + line); + } + } + private static Path prepareCodenameOneSettings() throws Exception { Path tempProject = Files.createTempDirectory("cn1-javase-sim-project"); Path settings = tempProject.resolve("codenameone_settings.properties"); @@ -147,6 +209,14 @@ private static Path configureSimulatorPreferences(Args args, Path projectDir) th System.setProperty("java.util.prefs.userRoot", prefsRoot.toAbsolutePath().toString()); Preferences prefs = Preferences.userNodeForPackage(com.codename1.impl.javase.JavaSEPort.class); prefs.putBoolean("Portrait", !"landscape".equals(args.scenario)); + if (args.nativeTheme != null) { + // Mirrors exactly what the "Native Theme" menu writes when + // the user picks an explicit theme - this is the lever the + // simulator menu acts on, so testing the lever directly + // covers the menu's reload path without driving the menu + // via AWT events. + prefs.put("simulatorNativeTheme", args.nativeTheme); + } prefs.flush(); return prefsRoot; } @@ -157,13 +227,16 @@ private static final class Args { final String simClasspath; final String skinPath; final String scenario; + final String nativeTheme; - private Args(String mode, String screenshotPath, String simClasspath, String skinPath, String scenario) { + private Args(String mode, String screenshotPath, String simClasspath, String skinPath, String scenario, + String nativeTheme) { this.mode = mode; this.screenshotPath = screenshotPath; this.simClasspath = simClasspath; this.skinPath = skinPath; this.scenario = scenario; + this.nativeTheme = nativeTheme; } static Args parse(String[] args) { @@ -172,6 +245,7 @@ static Args parse(String[] args) { String simClasspath = null; String skinPath = null; String scenario = "default"; + String nativeTheme = null; for (int i = 0; i < args.length; i++) { String arg = args[i]; if ("--mode".equals(arg) && i + 1 < args.length) { @@ -184,6 +258,8 @@ static Args parse(String[] args) { skinPath = args[++i]; } else if ("--scenario".equals(arg) && i + 1 < args.length) { scenario = args[++i]; + } else if ("--native-theme".equals(arg) && i + 1 < args.length) { + nativeTheme = args[++i]; } } if (!"single".equals(mode) && !"multi".equals(mode)) { @@ -195,7 +271,7 @@ static Args parse(String[] args) { if (simClasspath == null || simClasspath.trim().isEmpty()) { throw new IllegalArgumentException("--sim-classpath is required"); } - return new Args(mode, screenshot, simClasspath, skinPath, scenario); + return new Args(mode, screenshot, simClasspath, skinPath, scenario, nativeTheme); } } } diff --git a/scripts/javase/screenshots/README.md b/scripts/javase/screenshots/README.md index fb2210d2df..df3b4239ad 100644 --- a/scripts/javase/screenshots/README.md +++ b/scripts/javase/screenshots/README.md @@ -9,6 +9,20 @@ This directory stores baseline PNG files for JavaSE simulator integration screen - `javase-single-component-inspector.png` - `javase-single-network-monitor.png` - `javase-single-test-recorder.png` +- `javase-single-native-theme-ios-modern.png` +- `javase-single-native-theme-ios7.png` +- `javase-single-native-theme-android-material.png` +- `javase-single-native-theme-android-holo.png` The CI workflow compares generated simulator screenshots with these files. If screenshots differ (or are missing), CI uploads artifacts and posts a PR comment with visual previews. + +The `javase-single-native-theme-*` baselines exercise the Simulator's +"Native Theme" menu - each one runs with the corresponding +`simulatorNativeTheme` preference set, the same preference the menu +writes when the user picks a theme. The captured chrome should +visibly differ between them: iOS Modern shows rounded blue buttons +and the modern iOS title bar, iOS 7 shows the flat 2014-era style, +Android Material shows raised purple buttons + Material chrome, and +Android Holo Light shows the all-caps Holo button labels in light +blue. diff --git a/scripts/javase/screenshots/javase-multi-landscape.png b/scripts/javase/screenshots/javase-multi-landscape.png index f9a9ae6b26..0bd93c04da 100644 Binary files a/scripts/javase/screenshots/javase-multi-landscape.png and b/scripts/javase/screenshots/javase-multi-landscape.png differ diff --git a/scripts/javase/screenshots/javase-multi-window.png b/scripts/javase/screenshots/javase-multi-window.png index b8c0597cd1..554f1c5ab5 100644 Binary files a/scripts/javase/screenshots/javase-multi-window.png and b/scripts/javase/screenshots/javase-multi-window.png differ diff --git a/scripts/javase/screenshots/javase-single-component-inspector.png b/scripts/javase/screenshots/javase-single-component-inspector.png index 1058f86764..d3fdd7f66e 100644 Binary files a/scripts/javase/screenshots/javase-single-component-inspector.png and b/scripts/javase/screenshots/javase-single-component-inspector.png differ diff --git a/scripts/javase/screenshots/javase-single-landscape.png b/scripts/javase/screenshots/javase-single-landscape.png index 4ac64099a0..62861b956a 100644 Binary files a/scripts/javase/screenshots/javase-single-landscape.png and b/scripts/javase/screenshots/javase-single-landscape.png differ diff --git a/scripts/javase/screenshots/javase-single-native-theme-android-holo.png b/scripts/javase/screenshots/javase-single-native-theme-android-holo.png new file mode 100644 index 0000000000..0379da5cc3 Binary files /dev/null and b/scripts/javase/screenshots/javase-single-native-theme-android-holo.png differ diff --git a/scripts/javase/screenshots/javase-single-native-theme-android-material.png b/scripts/javase/screenshots/javase-single-native-theme-android-material.png new file mode 100644 index 0000000000..c9d6e99b7e Binary files /dev/null and b/scripts/javase/screenshots/javase-single-native-theme-android-material.png differ diff --git a/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png b/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png new file mode 100644 index 0000000000..f990150efe Binary files /dev/null and b/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png differ diff --git a/scripts/javase/screenshots/javase-single-native-theme-ios7.png b/scripts/javase/screenshots/javase-single-native-theme-ios7.png new file mode 100644 index 0000000000..85572c6424 Binary files /dev/null and b/scripts/javase/screenshots/javase-single-native-theme-ios7.png differ diff --git a/scripts/javase/screenshots/javase-single-network-monitor.png b/scripts/javase/screenshots/javase-single-network-monitor.png index 2bbfb4dd74..fc0d0f8180 100644 Binary files a/scripts/javase/screenshots/javase-single-network-monitor.png and b/scripts/javase/screenshots/javase-single-network-monitor.png differ diff --git a/scripts/javase/screenshots/javase-single-test-recorder.png b/scripts/javase/screenshots/javase-single-test-recorder.png index 1b5e5a1321..17e44fd9ad 100644 Binary files a/scripts/javase/screenshots/javase-single-test-recorder.png and b/scripts/javase/screenshots/javase-single-test-recorder.png differ diff --git a/scripts/javase/screenshots/javase-single-window.png b/scripts/javase/screenshots/javase-single-window.png index 365bff3168..25253c8ae8 100644 Binary files a/scripts/javase/screenshots/javase-single-window.png and b/scripts/javase/screenshots/javase-single-window.png differ diff --git a/scripts/run-javase-simulator-integration-tests.sh b/scripts/run-javase-simulator-integration-tests.sh index cb6e7220c7..f7376642c4 100755 --- a/scripts/run-javase-simulator-integration-tests.sh +++ b/scripts/run-javase-simulator-integration-tests.sh @@ -126,6 +126,15 @@ js_log "Ensuring CLDC11 port is built (required bootclasspath for CodenameOne co js_log "Using Java 8 for ant build: $JAVA8_HOME_DETECTED" JAVA_HOME="$JAVA8_HOME_DETECTED" PATH="$JAVA8_HOME_DETECTED/bin:$PATH" ant -noinput -buildfile Ports/CLDC11/build.xml jar +# Compile the CSS-driven native themes (iOSModernTheme.res, +# AndroidMaterialTheme.res). The JavaSE ant build copies them out of +# Themes/, with failonerror=false, so a missing pair would silently +# produce a JavaSE.jar without the modern themes - which is precisely +# the failure mode the native-theme verification below catches. Run +# the compiler ourselves so a fresh checkout doesn't surprise CI. +js_log "Building CSS-driven native themes" +"$REPO_ROOT/scripts/build-native-themes.sh" + js_log "Ensuring JavaSE port is built" js_log "Using Java 8 for ant build: $JAVA8_HOME_DETECTED" JAVA_HOME="$JAVA8_HOME_DETECTED" PATH="$JAVA8_HOME_DETECTED/bin:$PATH" ant -noinput -buildfile Ports/JavaSE/build.xml jar @@ -177,6 +186,52 @@ for mode in "${MODES[@]}"; do done done +# Native-theme override regression coverage. The Simulator's "Native +# Theme" menu writes simulatorNativeTheme and triggers a simulator +# reload; the bundled themes (iOSModernTheme, AndroidMaterialTheme, +# the iOS7 / Android Holo legacy themes) must all resolve through +# JavaSEPort.loadSkinFile's override branch. We exercise each one +# end-to-end: set the preference the menu would set, restart the +# simulator the same way the menu does, and assert the running app +# sees a Resources object whose first theme name matches what we'd +# get from opening the bundled .res directly. This catches both +# "menu wrote the wrong preference" and "preference wasn't honored +# on reload" regressions. +NATIVE_THEME_SCENARIOS=( + "iOSModernTheme" + "iOS7Theme" + "AndroidMaterialTheme" + "android_holo_light" +) +for theme_key in "${NATIVE_THEME_SCENARIOS[@]}"; do + case "$theme_key" in + iOSModernTheme) scenario_label=ios-modern ;; + iOS7Theme) scenario_label=ios7 ;; + AndroidMaterialTheme) scenario_label=android-material ;; + android_holo_light) scenario_label=android-holo ;; + *) scenario_label="$theme_key" ;; + esac + test_name="javase-single-native-theme-${scenario_label}" + png="$RAW_DIR/${test_name}.png" + js_log "Running simulator native-theme verification for ${theme_key}" + FULL_SIM_CLASSPATH="$CN1_CLASSPATH:$CLASS_DIR" + xvfb-run -a -s "-screen 0 2200x1400x24" "$JAVA_BIN" -Djava.awt.headless=false \ + -cp "$FULL_SIM_CLASSPATH" \ + com.codenameone.examples.javase.tests.SimulatorWindowModeVerifier \ + --mode single \ + --scenario "native-theme-${scenario_label}" \ + --sim-classpath "$FULL_SIM_CLASSPATH" \ + --skin "$SIM_SKIN_PATH" \ + --screenshot "$png" \ + --native-theme "$theme_key" + + if [ ! -s "$png" ]; then + js_log "Expected screenshot was not produced for native-theme ${theme_key}" >&2 + exit 11 + fi + ACTUAL_ENTRIES+=("${test_name}=$png") +done + COMPARE_JSON="$ARTIFACTS_DIR/screenshot-compare.json" SUMMARY_FILE="$ARTIFACTS_DIR/screenshot-summary.txt" COMMENT_FILE="$ARTIFACTS_DIR/screenshot-comment.md"