From cd90bdcbbf5b2667b3446d53bee17f0ef093b5f2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:51:20 +0200 Subject: [PATCH 1/9] Add tool to convert AVD skins to Codename One skins --- AvdSkinToCodenameOneSkin.java | 421 ++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 AvdSkinToCodenameOneSkin.java diff --git a/AvdSkinToCodenameOneSkin.java b/AvdSkinToCodenameOneSkin.java new file mode 100644 index 0000000..4ef1d0d --- /dev/null +++ b/AvdSkinToCodenameOneSkin.java @@ -0,0 +1,421 @@ +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Command line utility that converts a standard Android emulator skin into a + * Codename One skin archive. + * + *

The tool expects the path to an Android Virtual Device (AVD) skin + * directory. The directory should contain the standard {@code layout} file and + * associated images (portrait/landscape). A {@code hardware.ini} file is used + * to infer hardware characteristics such as density.

+ * + *

Usage:

+ *
+ *   java AvdSkinToCodenameOneSkin.java /path/to/avd/skin [output.skin]
+ * 
+ * + *

If the output file path isn't supplied the converter generates a + * {@code .skin} file next to the AVD skin directory using the directory's name.

+ */ +public class AvdSkinToCodenameOneSkin { + + private static final double TABLET_INCH_THRESHOLD = 6.5d; + + public static void main(String[] args) throws Exception { + if (args.length == 0 || args.length > 2) { + System.err.println("Usage: java AvdSkinToCodenameOneSkin.java [output.skin]"); + System.exit(1); + } + + Path skinDirectory = Paths.get(args[0]).toAbsolutePath().normalize(); + if (!Files.isDirectory(skinDirectory)) { + error("Input path %s is not a directory".formatted(skinDirectory)); + } + + Path outputFile; + if (args.length == 2) { + outputFile = Paths.get(args[1]).toAbsolutePath().normalize(); + } else { + outputFile = skinDirectory.getParent().resolve(skinDirectory.getFileName().toString() + ".skin"); + } + + if (Files.exists(outputFile)) { + error("Output file %s already exists".formatted(outputFile)); + } + + LayoutInfo layoutInfo = LayoutInfo.parse(findLayoutFile(skinDirectory)); + HardwareInfo hardwareInfo = HardwareInfo.parse(skinDirectory.resolve("hardware.ini")); + + if (!layoutInfo.hasBothOrientations()) { + error("Layout file must define portrait and landscape display information"); + } + + DeviceImages portraitImages = buildDeviceImages(skinDirectory, layoutInfo.portrait()); + DeviceImages landscapeImages = buildDeviceImages(skinDirectory, layoutInfo.landscape()); + + boolean isTablet = hardwareInfo.isTabletLike(TABLET_INCH_THRESHOLD); + String overrideNames = isTablet ? "tablet,android,android-tablet" : "phone,android,android-phone"; + + Properties props = new Properties(); + props.setProperty("touch", "true"); + props.setProperty("platformName", "and"); + props.setProperty("tablet", Boolean.toString(isTablet)); + props.setProperty("systemFontFamily", "Roboto"); + props.setProperty("proportionalFontFamily", "Roboto"); + props.setProperty("monospaceFontFamily", "Droid Sans Mono"); + props.setProperty("smallFontSize", "11"); + props.setProperty("mediumFontSize", "14"); + props.setProperty("largeFontSize", "20"); + props.setProperty("pixelRatio", String.format(Locale.US, "%.6f", hardwareInfo.pixelRatio())); + props.setProperty("overrideNames", overrideNames); + + Path parent = outputFile.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(outputFile))) { + writeEntry(zos, "skin.png", portraitImages.withTransparentDisplay()); + writeEntry(zos, "skin_l.png", landscapeImages.withTransparentDisplay()); + writeEntry(zos, "skin_map.png", portraitImages.overlay()); + writeEntry(zos, "skin_map_l.png", landscapeImages.overlay()); + writeProperties(zos, props); + } + + System.out.println("Codename One skin created at: " + outputFile); + } + + private static Path findLayoutFile(Path skinDirectory) { + Path layout = skinDirectory.resolve("layout"); + if (Files.isRegularFile(layout)) { + return layout; + } + // Some skins place the layout inside a nested directory, search one level deep. + try { + List candidates = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(skinDirectory)) { + for (Path child : stream) { + if (Files.isRegularFile(child) && child.getFileName().toString().equalsIgnoreCase("layout")) { + candidates.add(child); + } else if (Files.isDirectory(child)) { + Path nested = child.resolve("layout"); + if (Files.isRegularFile(nested)) { + candidates.add(nested); + } + } + } + } + if (!candidates.isEmpty()) { + if (candidates.size() > 1) { + throw new IllegalStateException("Multiple layout files detected within " + skinDirectory); + } + return candidates.get(0); + } + } catch (IOException err) { + throw new UncheckedIOException("Failed to locate layout file", err); + } + throw new IllegalStateException("Unable to locate layout file inside " + skinDirectory); + } + + private static DeviceImages buildDeviceImages(Path skinDir, OrientationInfo orientation) { + Path imagePath = skinDir.resolve(orientation.imageName()); + if (!Files.isRegularFile(imagePath)) { + throw new IllegalStateException("Missing image '" + orientation.imageName() + "' for " + orientation.orientation()); + } + try { + BufferedImage original = javax.imageio.ImageIO.read(imagePath.toFile()); + if (original == null) { + throw new IllegalStateException("Failed to decode image " + imagePath); + } + if (orientation.display().width() <= 0 || orientation.display().height() <= 0) { + throw new IllegalStateException("Invalid display dimensions for " + orientation.orientation()); + } + return new DeviceImages(original, orientation.display()); + } catch (IOException err) { + throw new UncheckedIOException("Failed to read image " + imagePath, err); + } + } + + private static void writeEntry(ZipOutputStream zos, String name, BufferedImage image) throws IOException { + ZipEntry entry = new ZipEntry(name); + zos.putNextEntry(entry); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(image, "png", baos); + zos.write(baos.toByteArray()); + zos.closeEntry(); + } + + private static void writeProperties(ZipOutputStream zos, Properties props) throws IOException { + ZipEntry entry = new ZipEntry("skin.properties"); + zos.putNextEntry(entry); + String comment = "Created by AvdSkinToCodenameOneSkin on " + LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + props.store(baos, comment); + zos.write(baos.toByteArray()); + zos.closeEntry(); + } + + private static void error(String message) { + System.err.println("Error: " + message); + System.exit(1); + } + + private record DeviceImages(BufferedImage original, DisplayArea display) { + BufferedImage withTransparentDisplay() { + BufferedImage result = new BufferedImage(original.getWidth(), original.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = result.createGraphics(); + try { + g.setComposite(AlphaComposite.Src); + g.drawImage(original, 0, 0, null); + g.setComposite(AlphaComposite.Clear); + g.fillRect(display.x(), display.y(), display.width(), display.height()); + } finally { + g.dispose(); + } + return result; + } + + BufferedImage overlay() { + BufferedImage overlay = new BufferedImage(original.getWidth(), original.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = overlay.createGraphics(); + try { + g.setComposite(AlphaComposite.Src); + g.setColor(Color.BLACK); + g.fillRect(display.x(), display.y(), display.width(), display.height()); + } finally { + g.dispose(); + } + return overlay; + } + } + + private record DisplayArea(int x, int y, int width, int height) {} + + private enum OrientationType { + PORTRAIT, + LANDSCAPE + } + + private record OrientationInfo(OrientationType orientation, String imageName, DisplayArea display) {} + + private record LayoutInfo(OrientationInfo portrait, OrientationInfo landscape) { + boolean hasBothOrientations() { + return portrait != null && landscape != null; + } + + static LayoutInfo parse(Path layoutFile) { + try { + return new LayoutParser().parse(Files.readString(layoutFile)); + } catch (IOException err) { + throw new UncheckedIOException("Failed to read layout file " + layoutFile, err); + } + } + } + + private static class LayoutParser { + private final EnumMap builders = new EnumMap<>(OrientationType.class); + private final Deque contextStack = new ArrayDeque<>(); + + LayoutInfo parse(String text) { + String[] lines = text.split("\r?\n"); + for (String rawLine : lines) { + String line = stripComments(rawLine).trim(); + if (line.isEmpty()) { + continue; + } + if (line.endsWith("{")) { + pushContext(line.substring(0, line.length() - 1).trim()); + } else if (line.equals("}")) { + popContext(); + } else { + handleKeyValue(line); + } + } + OrientationInfo portrait = builders.containsKey(OrientationType.PORTRAIT) + ? builders.get(OrientationType.PORTRAIT).build(OrientationType.PORTRAIT) + : null; + OrientationInfo landscape = builders.containsKey(OrientationType.LANDSCAPE) + ? builders.get(OrientationType.LANDSCAPE).build(OrientationType.LANDSCAPE) + : null; + return new LayoutInfo(portrait, landscape); + } + + private void handleKeyValue(String line) { + Context ctx = contextStack.peek(); + if (ctx == null) { + return; + } + OrientationType orientation = findCurrentOrientation(); + if (orientation == null) { + return; + } + OrientationInfoBuilder builder = builders.computeIfAbsent(orientation, o -> new OrientationInfoBuilder()); + String[] parts = splitKeyValue(line); + if (parts == null) { + return; + } + String key = parts[0]; + String value = parts[1]; + String ctxName = ctx.name.toLowerCase(Locale.ROOT); + if (ctxName.contains("image") && key.equalsIgnoreCase("name")) { + builder.imageName = value; + } else if (ctxName.contains("display")) { + switch (key.toLowerCase(Locale.ROOT)) { + case "x" -> builder.displayX = parseInt(value); + case "y" -> builder.displayY = parseInt(value); + case "width" -> builder.displayWidth = parseInt(value); + case "height" -> builder.displayHeight = parseInt(value); + } + } + } + + private void pushContext(String name) { + name = name.trim(); + if (name.isEmpty()) { + contextStack.push(new Context("", null)); + return; + } + OrientationType orientation = detectOrientation(name); + contextStack.push(new Context(name, orientation)); + } + + private void popContext() { + if (!contextStack.isEmpty()) { + contextStack.pop(); + } + } + + private OrientationType findCurrentOrientation() { + for (Context ctx : contextStack) { + if (ctx.orientation != null) { + return ctx.orientation; + } + } + return null; + } + + private String[] splitKeyValue(String line) { + String cleaned = line.replace('=', ' '); + String[] parts = cleaned.trim().split("\\s+", 2); + if (parts.length != 2) { + return null; + } + return parts; + } + + private int parseInt(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException err) { + throw new IllegalStateException("Invalid integer value '" + value + "' in layout file", err); + } + } + + private String stripComments(String line) { + int slash = line.indexOf("//"); + int hash = line.indexOf('#'); + int cut = -1; + if (slash >= 0) { + cut = slash; + } + if (hash >= 0 && (cut < 0 || hash < cut)) { + cut = hash; + } + if (cut >= 0) { + return line.substring(0, cut); + } + return line; + } + + private OrientationType detectOrientation(String name) { + String lower = name.toLowerCase(Locale.ROOT); + if (lower.contains("land") || lower.contains("horz")) { + return OrientationType.LANDSCAPE; + } + if (lower.contains("port") || lower.contains("vert")) { + return OrientationType.PORTRAIT; + } + return null; + } + + private record Context(String name, OrientationType orientation) {} + + private static class OrientationInfoBuilder { + String imageName; + Integer displayX; + Integer displayY; + Integer displayWidth; + Integer displayHeight; + + OrientationInfo build(OrientationType type) { + if (imageName == null || displayX == null || displayY == null || displayWidth == null || displayHeight == null) { + throw new IllegalStateException("Layout definition for " + type + " is incomplete"); + } + return new OrientationInfo(type, imageName, new DisplayArea(displayX, displayY, displayWidth, displayHeight)); + } + } + } + + private record HardwareInfo(int widthPixels, int heightPixels, double densityDpi) { + double pixelRatio() { + if (densityDpi <= 0) { + return 6.0d; + } + return densityDpi / 25.4d; + } + + boolean isTabletLike(double diagonalInchThreshold) { + double density = densityDpi > 0 ? densityDpi : 320d; + double widthInches = widthPixels / density; + double heightInches = heightPixels / density; + double diagonal = Math.hypot(widthInches, heightInches); + return diagonal >= diagonalInchThreshold; + } + + static HardwareInfo parse(Path hardwareIni) { + Properties props = new Properties(); + if (Files.isRegularFile(hardwareIni)) { + try (BufferedReader reader = Files.newBufferedReader(hardwareIni)) { + props.load(reader); + } catch (IOException err) { + throw new UncheckedIOException("Failed to read hardware.ini", err); + } + } + int width = parseInt(props.getProperty("hw.lcd.width"), 1080); + int height = parseInt(props.getProperty("hw.lcd.height"), 1920); + double density = parseDouble(props.getProperty("hw.lcd.density"), + parseDouble(props.getProperty("hw.lcd.pixelDensity"), 420d)); + return new HardwareInfo(width, height, density); + } + + private static int parseInt(String value, int defaultValue) { + if (value == null || value.isEmpty()) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException err) { + return defaultValue; + } + } + + private static double parseDouble(String value, double defaultValue) { + if (value == null || value.isEmpty()) { + return defaultValue; + } + try { + return Double.parseDouble(value.trim()); + } catch (NumberFormatException err) { + return defaultValue; + } + } + } +} From 76bd35245569823aa3bc139accf727fffffbaa6b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:27:00 +0200 Subject: [PATCH 2/9] Add CI workflow to validate AVD skin conversion --- .github/workflows/avd-skin-conversion.yml | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/avd-skin-conversion.yml diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml new file mode 100644 index 0000000..90506bb --- /dev/null +++ b/.github/workflows/avd-skin-conversion.yml @@ -0,0 +1,34 @@ +name: AVD Skin Conversion + +on: + push: + pull_request: + +jobs: + convert: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Download sample AVD skin + run: | + curl -L -o skins.zip https://github.com/google/android-emulator-skins/archive/refs/heads/main.zip + mkdir -p skins + unzip -q skins.zip "android-emulator-skins-main/Pixel_4/*" -d skins + + - name: Convert AVD skin to Codename One skin + run: | + mkdir -p build + java AvdSkinToCodenameOneSkin.java skins/android-emulator-skins-main/Pixel_4 build/Pixel_4.skin + + - name: Validate Codename One skin archive + run: | + test -f build/Pixel_4.skin + unzip -l build/Pixel_4.skin From 83fb59248cb0e8e3cbf4be5f7f89f06dab16dbd8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:27:05 +0200 Subject: [PATCH 3/9] Restrict AVD workflow triggers --- .github/workflows/avd-skin-conversion.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml index 90506bb..8c0431c 100644 --- a/.github/workflows/avd-skin-conversion.yml +++ b/.github/workflows/avd-skin-conversion.yml @@ -2,7 +2,13 @@ name: AVD Skin Conversion on: push: + paths: + - '.github/workflows/avd-skin-conversion.yml' + - 'AvdSkinToCodenameOneSkin.java' pull_request: + paths: + - '.github/workflows/avd-skin-conversion.yml' + - 'AvdSkinToCodenameOneSkin.java' jobs: convert: From ee32dd303ea33ff7a50669e9f0df4db0f9a3368b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:37:02 +0200 Subject: [PATCH 4/9] Improve AVD layout parsing and CI workflow --- .github/workflows/avd-skin-conversion.yml | 7 +- AvdSkinToCodenameOneSkin.java | 114 ++++++++++++++++++++-- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml index 8c0431c..9b0d467 100644 --- a/.github/workflows/avd-skin-conversion.yml +++ b/.github/workflows/avd-skin-conversion.yml @@ -1,14 +1,11 @@ name: AVD Skin Conversion on: - push: - paths: - - '.github/workflows/avd-skin-conversion.yml' - - 'AvdSkinToCodenameOneSkin.java' pull_request: paths: - '.github/workflows/avd-skin-conversion.yml' - 'AvdSkinToCodenameOneSkin.java' + workflow_dispatch: jobs: convert: @@ -25,7 +22,7 @@ jobs: - name: Download sample AVD skin run: | - curl -L -o skins.zip https://github.com/google/android-emulator-skins/archive/refs/heads/main.zip + curl -L --fail -o skins.zip https://codeload.github.com/google/android-emulator-skins/zip/refs/heads/main mkdir -p skins unzip -q skins.zip "android-emulator-skins-main/Pixel_4/*" -d skins diff --git a/AvdSkinToCodenameOneSkin.java b/AvdSkinToCodenameOneSkin.java index 4ef1d0d..f36e7b0 100644 --- a/AvdSkinToCodenameOneSkin.java +++ b/AvdSkinToCodenameOneSkin.java @@ -52,7 +52,8 @@ public static void main(String[] args) throws Exception { error("Output file %s already exists".formatted(outputFile)); } - LayoutInfo layoutInfo = LayoutInfo.parse(findLayoutFile(skinDirectory)); + Path layoutFile = findLayoutFile(skinDirectory); + LayoutInfo layoutInfo = LayoutInfo.parse(layoutFile, skinDirectory); HardwareInfo hardwareInfo = HardwareInfo.parse(skinDirectory.resolve("hardware.ini")); if (!layoutInfo.hasBothOrientations()) { @@ -211,9 +212,9 @@ boolean hasBothOrientations() { return portrait != null && landscape != null; } - static LayoutInfo parse(Path layoutFile) { + static LayoutInfo parse(Path layoutFile, Path skinDirectory) { try { - return new LayoutParser().parse(Files.readString(layoutFile)); + return new LayoutParser(layoutFile, skinDirectory).parse(Files.readString(layoutFile)); } catch (IOException err) { throw new UncheckedIOException("Failed to read layout file " + layoutFile, err); } @@ -223,6 +224,13 @@ static LayoutInfo parse(Path layoutFile) { private static class LayoutParser { private final EnumMap builders = new EnumMap<>(OrientationType.class); private final Deque contextStack = new ArrayDeque<>(); + private final Path skinDirectory; + private final Path layoutParent; + + LayoutParser(Path layoutFile, Path skinDirectory) { + this.skinDirectory = skinDirectory; + this.layoutParent = layoutFile.getParent(); + } LayoutInfo parse(String text) { String[] lines = text.split("\r?\n"); @@ -263,10 +271,10 @@ private void handleKeyValue(String line) { return; } String key = parts[0]; - String value = parts[1]; + String value = unquote(parts[1]); String ctxName = ctx.name.toLowerCase(Locale.ROOT); if (ctxName.contains("image") && key.equalsIgnoreCase("name")) { - builder.imageName = value; + builder.considerImage(value, contextStack, this::resolveImagePath); } else if (ctxName.contains("display")) { switch (key.toLowerCase(Locale.ROOT)) { case "x" -> builder.displayX = parseInt(value); @@ -311,6 +319,17 @@ private String[] splitKeyValue(String line) { return parts; } + private String unquote(String value) { + value = value.trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + return value.substring(1, value.length() - 1); + } + if (value.length() >= 2 && value.startsWith("'") && value.endsWith("'")) { + return value.substring(1, value.length() - 1); + } + return value; + } + private int parseInt(String value) { try { return Integer.parseInt(value); @@ -319,6 +338,20 @@ private int parseInt(String value) { } } + private Path resolveImagePath(String name) { + Path candidate = skinDirectory.resolve(name).normalize(); + if (Files.isRegularFile(candidate)) { + return candidate; + } + if (layoutParent != null) { + Path sibling = layoutParent.resolve(name).normalize(); + if (Files.isRegularFile(sibling)) { + return sibling; + } + } + return candidate; + } + private String stripComments(String line) { int slash = line.indexOf("//"); int hash = line.indexOf('#'); @@ -349,17 +382,82 @@ private OrientationType detectOrientation(String name) { private record Context(String name, OrientationType orientation) {} private static class OrientationInfoBuilder { - String imageName; + ImageCandidate selectedImage; Integer displayX; Integer displayY; Integer displayWidth; Integer displayHeight; + void considerImage(String name, Deque contexts, java.util.function.Function resolver) { + ImageCandidate candidate = ImageCandidate.from(name, contexts, resolver); + if (selectedImage == null || candidate.isBetterThan(selectedImage)) { + selectedImage = candidate; + } + } + OrientationInfo build(OrientationType type) { - if (imageName == null || displayX == null || displayY == null || displayWidth == null || displayHeight == null) { + if (selectedImage == null || displayX == null || displayY == null || displayWidth == null || displayHeight == null) { throw new IllegalStateException("Layout definition for " + type + " is incomplete"); } - return new OrientationInfo(type, imageName, new DisplayArea(displayX, displayY, displayWidth, displayHeight)); + return new OrientationInfo(type, selectedImage.name(), new DisplayArea(displayX, displayY, displayWidth, displayHeight)); + } + } + + private record ImageCandidate(String name, long area, boolean frameHint, boolean controlHint) { + static ImageCandidate from(String name, Deque contexts, java.util.function.Function resolver) { + boolean frameHint = false; + boolean controlHint = false; + for (Context ctx : contexts) { + String lower = ctx.name.toLowerCase(Locale.ROOT); + if (lower.contains("button") || lower.contains("control") || lower.contains("icon") || lower.contains("touch")) { + controlHint = true; + } + if (lower.contains("device") || lower.contains("frame") || lower.contains("skin") || lower.contains("phone") || lower.contains("tablet")) { + frameHint = true; + } + } + String lowerName = name.toLowerCase(Locale.ROOT); + if (lowerName.contains("frame") || lowerName.contains("device") || lowerName.contains("shell") || lowerName.contains("body")) { + frameHint = true; + } + if (lowerName.contains("button") || lowerName.contains("control") || lowerName.contains("icon")) { + controlHint = true; + } + long area = computeArea(resolver.apply(name)); + return new ImageCandidate(name, area, frameHint, controlHint); + } + + private static long computeArea(Path imagePath) { + if (imagePath == null || !Files.isRegularFile(imagePath)) { + return -1; + } + try { + BufferedImage img = javax.imageio.ImageIO.read(imagePath.toFile()); + if (img == null) { + return -1; + } + return (long) img.getWidth() * (long) img.getHeight(); + } catch (IOException err) { + return -1; + } + } + + boolean isBetterThan(ImageCandidate other) { + if (other == null) { + return true; + } + if (frameHint != other.frameHint) { + return frameHint && !controlHint; + } + if (controlHint != other.controlHint) { + return !controlHint; + } + long thisArea = Math.max(area, 0); + long otherArea = Math.max(other.area, 0); + if (thisArea != otherArea) { + return thisArea > otherArea; + } + return name.compareTo(other.name) < 0; } } } From 600e8028026799642442bc9033483a470fbc4fb7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Nov 2025 05:28:31 +0200 Subject: [PATCH 5/9] Harden workflow download and avoid duplicate runs --- .github/workflows/avd-skin-conversion.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml index 9b0d467..1990f3d 100644 --- a/.github/workflows/avd-skin-conversion.yml +++ b/.github/workflows/avd-skin-conversion.yml @@ -7,6 +7,10 @@ on: - 'AvdSkinToCodenameOneSkin.java' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: convert: runs-on: ubuntu-latest @@ -22,14 +26,15 @@ jobs: - name: Download sample AVD skin run: | - curl -L --fail -o skins.zip https://codeload.github.com/google/android-emulator-skins/zip/refs/heads/main + set -euo pipefail + curl -L --fail --retry 5 --retry-delay 5 -o skins.zip https://codeload.github.com/google/android-emulator-skins/zip/refs/heads/master mkdir -p skins - unzip -q skins.zip "android-emulator-skins-main/Pixel_4/*" -d skins + unzip -q skins.zip "android-emulator-skins-master/Pixel_4/*" -d skins - name: Convert AVD skin to Codename One skin run: | mkdir -p build - java AvdSkinToCodenameOneSkin.java skins/android-emulator-skins-main/Pixel_4 build/Pixel_4.skin + java AvdSkinToCodenameOneSkin.java skins/android-emulator-skins-master/Pixel_4 build/Pixel_4.skin - name: Validate Codename One skin archive run: | From 9134f930312d5ceeb8caa25de6d51cdb9e363849 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Nov 2025 05:57:22 +0200 Subject: [PATCH 6/9] Handle WebP AVD skins and narrow CI trigger --- .github/workflows/avd-skin-conversion.yml | 11 ++-- AvdSkinToCodenameOneSkin.java | 71 ++++++++++++++++++++--- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml index 1990f3d..0399336 100644 --- a/.github/workflows/avd-skin-conversion.yml +++ b/.github/workflows/avd-skin-conversion.yml @@ -13,6 +13,7 @@ concurrency: jobs: convert: + if: ${{ github.event_name != 'pull_request' || github.event.action != 'synchronize' || github.event.before != '0000000000000000000000000000000000000000' }} runs-on: ubuntu-latest steps: - name: Checkout repository @@ -27,16 +28,16 @@ jobs: - name: Download sample AVD skin run: | set -euo pipefail - curl -L --fail --retry 5 --retry-delay 5 -o skins.zip https://codeload.github.com/google/android-emulator-skins/zip/refs/heads/master + curl -L --fail --retry 5 --retry-delay 5 -o skins.zip https://github.com/larskristianhaga/Android-emulator-skins/archive/refs/heads/master.zip mkdir -p skins - unzip -q skins.zip "android-emulator-skins-master/Pixel_4/*" -d skins + unzip -q skins.zip "Android-emulator-skins-master/nexus_10/*" -d skins - name: Convert AVD skin to Codename One skin run: | mkdir -p build - java AvdSkinToCodenameOneSkin.java skins/android-emulator-skins-master/Pixel_4 build/Pixel_4.skin + java AvdSkinToCodenameOneSkin.java skins/Android-emulator-skins-master/nexus_10 build/nexus_10.skin - name: Validate Codename One skin archive run: | - test -f build/Pixel_4.skin - unzip -l build/Pixel_4.skin + test -f build/nexus_10.skin + unzip -l build/nexus_10.skin diff --git a/AvdSkinToCodenameOneSkin.java b/AvdSkinToCodenameOneSkin.java index f36e7b0..3dc4d9e 100644 --- a/AvdSkinToCodenameOneSkin.java +++ b/AvdSkinToCodenameOneSkin.java @@ -1,5 +1,7 @@ import java.awt.*; import java.awt.image.BufferedImage; +import java.awt.image.ImageObserver; +import java.awt.image.PixelGrabber; import java.io.*; import java.nio.file.*; import java.time.LocalDateTime; @@ -31,6 +33,8 @@ public class AvdSkinToCodenameOneSkin { private static final double TABLET_INCH_THRESHOLD = 6.5d; public static void main(String[] args) throws Exception { + System.setProperty("java.awt.headless", "true"); + if (args.length == 0 || args.length > 2) { System.err.println("Usage: java AvdSkinToCodenameOneSkin.java [output.skin]"); System.exit(1); @@ -132,10 +136,7 @@ private static DeviceImages buildDeviceImages(Path skinDir, OrientationInfo orie throw new IllegalStateException("Missing image '" + orientation.imageName() + "' for " + orientation.orientation()); } try { - BufferedImage original = javax.imageio.ImageIO.read(imagePath.toFile()); - if (original == null) { - throw new IllegalStateException("Failed to decode image " + imagePath); - } + BufferedImage original = readImage(imagePath); if (orientation.display().width() <= 0 || orientation.display().height() <= 0) { throw new IllegalStateException("Invalid display dimensions for " + orientation.orientation()); } @@ -145,6 +146,53 @@ private static DeviceImages buildDeviceImages(Path skinDir, OrientationInfo orie } } + private static BufferedImage readImage(Path imagePath) throws IOException { + BufferedImage standard = javax.imageio.ImageIO.read(imagePath.toFile()); + if (standard != null) { + return standard; + } + + byte[] data = Files.readAllBytes(imagePath); + Image toolkitImage; + try { + toolkitImage = Toolkit.getDefaultToolkit().createImage(data); + } catch (HeadlessException err) { + throw new IllegalStateException("Unsupported image format for " + imagePath + " (headless toolkit)", err); + } + if (toolkitImage == null) { + throw new IllegalStateException("Unsupported image format for " + imagePath); + } + PixelGrabber grabber = new PixelGrabber(toolkitImage, 0, 0, -1, -1, true); + try { + grabber.grabPixels(); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while decoding " + imagePath, err); + } + if (grabber.getStatus() != ImageObserver.ALLBITS) { + throw new IllegalStateException("Failed to decode image " + imagePath); + } + int width = grabber.getWidth(); + int height = grabber.getHeight(); + if (width <= 0 || height <= 0) { + throw new IllegalStateException("Failed to decode image " + imagePath); + } + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Object pixels = grabber.getPixels(); + if (!(pixels instanceof int[] rgb)) { + throw new IllegalStateException("Unsupported pixel model in " + imagePath); + } + Graphics2D g = result.createGraphics(); + try { + g.setComposite(AlphaComposite.Src); + g.drawImage(toolkitImage, 0, 0, null); + result.setRGB(0, 0, width, height, rgb, 0, width); + } finally { + g.dispose(); + } + return result; + } + private static void writeEntry(ZipOutputStream zos, String name, BufferedImage image) throws IOException { ZipEntry entry = new ZipEntry(name); zos.putNextEntry(entry); @@ -273,7 +321,7 @@ private void handleKeyValue(String line) { String key = parts[0]; String value = unquote(parts[1]); String ctxName = ctx.name.toLowerCase(Locale.ROOT); - if (ctxName.contains("image") && key.equalsIgnoreCase("name")) { + if (ctxName.contains("image") && isImageKey(key)) { builder.considerImage(value, contextStack, this::resolveImagePath); } else if (ctxName.contains("display")) { switch (key.toLowerCase(Locale.ROOT)) { @@ -285,6 +333,11 @@ private void handleKeyValue(String line) { } } + private boolean isImageKey(String key) { + String lower = key.toLowerCase(Locale.ROOT); + return lower.equals("name") || lower.equals("image") || lower.equals("filename"); + } + private void pushContext(String name) { name = name.trim(); if (name.isEmpty()) { @@ -409,18 +462,18 @@ static ImageCandidate from(String name, Deque contexts, java.util.funct boolean controlHint = false; for (Context ctx : contexts) { String lower = ctx.name.toLowerCase(Locale.ROOT); - if (lower.contains("button") || lower.contains("control") || lower.contains("icon") || lower.contains("touch")) { + if (lower.contains("button") || lower.contains("control") || lower.contains("icon") || lower.contains("touch") || lower.contains("shadow") || lower.contains("onion")) { controlHint = true; } - if (lower.contains("device") || lower.contains("frame") || lower.contains("skin") || lower.contains("phone") || lower.contains("tablet")) { + if (lower.contains("device") || lower.contains("frame") || lower.contains("skin") || lower.contains("phone") || lower.contains("tablet") || lower.contains("background") || lower.contains("back")) { frameHint = true; } } String lowerName = name.toLowerCase(Locale.ROOT); - if (lowerName.contains("frame") || lowerName.contains("device") || lowerName.contains("shell") || lowerName.contains("body")) { + if (lowerName.contains("frame") || lowerName.contains("device") || lowerName.contains("shell") || lowerName.contains("body") || lowerName.contains("background") || lowerName.contains("back") || lowerName.contains("fore")) { frameHint = true; } - if (lowerName.contains("button") || lowerName.contains("control") || lowerName.contains("icon")) { + if (lowerName.contains("button") || lowerName.contains("control") || lowerName.contains("icon") || lowerName.contains("shadow") || lowerName.contains("onion")) { controlHint = true; } long area = computeArea(resolver.apply(name)); From 530d0df36b9e046387cfc8093f07bcac097bdba0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:25:45 +0200 Subject: [PATCH 7/9] Handle shared display geometry in layout parser --- AvdSkinToCodenameOneSkin.java | 145 +++++++++++++++++++++++++++++----- 1 file changed, 126 insertions(+), 19 deletions(-) diff --git a/AvdSkinToCodenameOneSkin.java b/AvdSkinToCodenameOneSkin.java index 3dc4d9e..ac4ce13 100644 --- a/AvdSkinToCodenameOneSkin.java +++ b/AvdSkinToCodenameOneSkin.java @@ -274,6 +274,10 @@ private static class LayoutParser { private final Deque contextStack = new ArrayDeque<>(); private final Path skinDirectory; private final Path layoutParent; + private Integer baseDisplayX; + private Integer baseDisplayY; + private Integer baseDisplayWidth; + private Integer baseDisplayHeight; LayoutParser(Path layoutFile, Path skinDirectory) { this.skinDirectory = skinDirectory; @@ -296,10 +300,10 @@ LayoutInfo parse(String text) { } } OrientationInfo portrait = builders.containsKey(OrientationType.PORTRAIT) - ? builders.get(OrientationType.PORTRAIT).build(OrientationType.PORTRAIT) + ? builders.get(OrientationType.PORTRAIT).build(OrientationType.PORTRAIT, this) : null; OrientationInfo landscape = builders.containsKey(OrientationType.LANDSCAPE) - ? builders.get(OrientationType.LANDSCAPE).build(OrientationType.LANDSCAPE) + ? builders.get(OrientationType.LANDSCAPE).build(OrientationType.LANDSCAPE, this) : null; return new LayoutInfo(portrait, landscape); } @@ -310,25 +314,49 @@ private void handleKeyValue(String line) { return; } OrientationType orientation = findCurrentOrientation(); - if (orientation == null) { - return; - } - OrientationInfoBuilder builder = builders.computeIfAbsent(orientation, o -> new OrientationInfoBuilder()); + String[] parts = splitKeyValue(line); if (parts == null) { return; } String key = parts[0]; String value = unquote(parts[1]); + + if (orientation == null && isInDeviceDisplayContext()) { + switch (key.toLowerCase(Locale.ROOT)) { + case "x" -> baseDisplayX = parseInt(value); + case "y" -> baseDisplayY = parseInt(value); + case "width" -> baseDisplayWidth = parseInt(value); + case "height" -> baseDisplayHeight = parseInt(value); + } + return; + } + + if (orientation == null) { + if (ctx.isPartBlock && key.equalsIgnoreCase("name")) { + ctx.devicePart = value.equalsIgnoreCase("device"); + } + return; + } + OrientationInfoBuilder builder = builders.computeIfAbsent(orientation, o -> new OrientationInfoBuilder()); String ctxName = ctx.name.toLowerCase(Locale.ROOT); - if (ctxName.contains("image") && isImageKey(key)) { + if (ctx.isPartBlock && key.equalsIgnoreCase("name")) { + ctx.devicePart = value.equalsIgnoreCase("device"); + } + if (isImageKey(key) && shouldTreatAsImage(ctx, key)) { builder.considerImage(value, contextStack, this::resolveImagePath); } else if (ctxName.contains("display")) { switch (key.toLowerCase(Locale.ROOT)) { - case "x" -> builder.displayX = parseInt(value); - case "y" -> builder.displayY = parseInt(value); - case "width" -> builder.displayWidth = parseInt(value); - case "height" -> builder.displayHeight = parseInt(value); + case "x" -> builder.displayXOverride = parseInt(value); + case "y" -> builder.displayYOverride = parseInt(value); + case "width" -> builder.displayWidthOverride = parseInt(value); + case "height" -> builder.displayHeightOverride = parseInt(value); + } + } else if (isInDevicePartContext()) { + switch (key.toLowerCase(Locale.ROOT)) { + case "x" -> builder.offsetX = parseInt(value); + case "y" -> builder.offsetY = parseInt(value); + case "rotation" -> builder.rotation = parseInt(value); } } } @@ -338,6 +366,23 @@ private boolean isImageKey(String key) { return lower.equals("name") || lower.equals("image") || lower.equals("filename"); } + private boolean shouldTreatAsImage(Context ctx, String key) { + if (!key.equalsIgnoreCase("name")) { + return true; + } + String ctxName = ctx.name.toLowerCase(Locale.ROOT); + return ctxName.contains("image") + || ctxName.contains("background") + || ctxName.contains("foreground") + || ctxName.contains("frame") + || ctxName.contains("skin") + || ctxName.contains("device") + || ctxName.contains("phone") + || ctxName.contains("tablet") + || ctxName.contains("onion") + || ctxName.contains("overlay"); + } + private void pushContext(String name) { name = name.trim(); if (name.isEmpty()) { @@ -432,14 +477,53 @@ private OrientationType detectOrientation(String name) { return null; } - private record Context(String name, OrientationType orientation) {} + private boolean isInDeviceDisplayContext() { + Iterator it = contextStack.iterator(); + if (!it.hasNext()) { + return false; + } + Context top = it.next(); + if (!top.name.equalsIgnoreCase("display")) { + return false; + } + if (!it.hasNext()) { + return false; + } + Context parent = it.next(); + return parent.name.equalsIgnoreCase("device"); + } + + private boolean isInDevicePartContext() { + for (Context ctx : contextStack) { + if (ctx.isPartBlock && ctx.devicePart) { + return true; + } + } + return false; + } + + private static final class Context { + final String name; + final OrientationType orientation; + final boolean isPartBlock; + boolean devicePart; + + Context(String name, OrientationType orientation) { + this.name = name; + this.orientation = orientation; + this.isPartBlock = name != null && name.toLowerCase(Locale.ROOT).startsWith("part"); + } + } private static class OrientationInfoBuilder { ImageCandidate selectedImage; - Integer displayX; - Integer displayY; - Integer displayWidth; - Integer displayHeight; + Integer displayXOverride; + Integer displayYOverride; + Integer displayWidthOverride; + Integer displayHeightOverride; + Integer offsetX; + Integer offsetY; + Integer rotation; void considerImage(String name, Deque contexts, java.util.function.Function resolver) { ImageCandidate candidate = ImageCandidate.from(name, contexts, resolver); @@ -448,11 +532,34 @@ void considerImage(String name, Deque contexts, java.util.function.Func } } - OrientationInfo build(OrientationType type) { - if (selectedImage == null || displayX == null || displayY == null || displayWidth == null || displayHeight == null) { + OrientationInfo build(OrientationType type, LayoutParser parser) { + if (selectedImage == null) { throw new IllegalStateException("Layout definition for " + type + " is incomplete"); } - return new OrientationInfo(type, selectedImage.name(), new DisplayArea(displayX, displayY, displayWidth, displayHeight)); + int baseX = parser.baseDisplayX != null ? parser.baseDisplayX : 0; + int baseY = parser.baseDisplayY != null ? parser.baseDisplayY : 0; + Integer widthSource = displayWidthOverride != null ? displayWidthOverride : parser.baseDisplayWidth; + Integer heightSource = displayHeightOverride != null ? displayHeightOverride : parser.baseDisplayHeight; + if (widthSource == null || heightSource == null) { + throw new IllegalStateException("Layout definition for " + type + " is missing display dimensions"); + } + int finalWidth = widthSource; + int finalHeight = heightSource; + int normalizedRotation = rotation != null ? Math.floorMod(rotation, 4) : 0; + if ((normalizedRotation & 1) == 1) { + int tmp = finalWidth; + finalWidth = finalHeight; + finalHeight = tmp; + } + int finalX = displayXOverride != null ? displayXOverride : baseX; + int finalY = displayYOverride != null ? displayYOverride : baseY; + if (offsetX != null) { + finalX += offsetX; + } + if (offsetY != null) { + finalY += offsetY; + } + return new OrientationInfo(type, selectedImage.name(), new DisplayArea(finalX, finalY, finalWidth, finalHeight)); } } From 30aa0f1e4f5896e8e2864ba345e253857008cff4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:56:28 +0200 Subject: [PATCH 8/9] Add WebP conversion fallback and install tools in CI --- .github/workflows/avd-skin-conversion.yml | 5 + AvdSkinToCodenameOneSkin.java | 124 +++++++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml index 0399336..de56df5 100644 --- a/.github/workflows/avd-skin-conversion.yml +++ b/.github/workflows/avd-skin-conversion.yml @@ -25,6 +25,11 @@ jobs: distribution: temurin java-version: '21' + - name: Install WebP conversion tools + run: | + sudo apt-get update + sudo apt-get install -y webp + - name: Download sample AVD skin run: | set -euo pipefail diff --git a/AvdSkinToCodenameOneSkin.java b/AvdSkinToCodenameOneSkin.java index ac4ce13..018c36c 100644 --- a/AvdSkinToCodenameOneSkin.java +++ b/AvdSkinToCodenameOneSkin.java @@ -3,6 +3,7 @@ import java.awt.image.ImageObserver; import java.awt.image.PixelGrabber; import java.io.*; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -10,6 +11,11 @@ import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import javax.imageio.ImageIO; +import javax.imageio.spi.IIORegistry; +import javax.imageio.spi.ImageReaderSpi; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; /** * Command line utility that converts a standard Android emulator skin into a @@ -30,11 +36,13 @@ */ public class AvdSkinToCodenameOneSkin { + static { + ensureWebpSupport(); + } + private static final double TABLET_INCH_THRESHOLD = 6.5d; public static void main(String[] args) throws Exception { - System.setProperty("java.awt.headless", "true"); - if (args.length == 0 || args.length > 2) { System.err.println("Usage: java AvdSkinToCodenameOneSkin.java [output.skin]"); System.exit(1); @@ -147,11 +155,48 @@ private static DeviceImages buildDeviceImages(Path skinDir, OrientationInfo orie } private static BufferedImage readImage(Path imagePath) throws IOException { - BufferedImage standard = javax.imageio.ImageIO.read(imagePath.toFile()); + String lowerName = imagePath.getFileName().toString().toLowerCase(Locale.ROOT); + + BufferedImage standard = ImageIO.read(imagePath.toFile()); if (standard != null) { return standard; } + try (InputStream in = Files.newInputStream(imagePath)) { + BufferedImage viaStream = ImageIO.read(in); + if (viaStream != null) { + return viaStream; + } + } + + try (ImageInputStream iis = ImageIO.createImageInputStream(Files.newInputStream(imagePath))) { + if (iis != null) { + Iterator readers = ImageIO.getImageReaders(iis); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + iis.seek(0); + reader.setInput(iis, true, true); + BufferedImage decoded = reader.read(0); + if (decoded != null) { + return decoded; + } + } finally { + reader.dispose(); + } + } + } + } + + BufferedImage viaDwebp = decodeWithDwebp(imagePath); + if (viaDwebp != null) { + return viaDwebp; + } + + if (GraphicsEnvironment.isHeadless() && lowerName.endsWith(".webp")) { + throw new IllegalStateException("WebP decoding requires the 'dwebp' command when running headless. Install the 'webp' package and ensure 'dwebp' is on the PATH."); + } + byte[] data = Files.readAllBytes(imagePath); Image toolkitImage; try { @@ -193,6 +238,79 @@ private static BufferedImage readImage(Path imagePath) throws IOException { return result; } + private static BufferedImage decodeWithDwebp(Path imagePath) throws IOException { + String lower = imagePath.getFileName().toString().toLowerCase(Locale.ROOT); + if (!lower.endsWith(".webp")) { + return null; + } + Path tempFile = null; + try { + tempFile = Files.createTempFile("avd-webp-", ".png"); + Process process = new ProcessBuilder("dwebp", imagePath.toString(), "-o", tempFile.toString()) + .redirectErrorStream(true) + .start(); + String output; + try (InputStream stdout = process.getInputStream()) { + output = new String(stdout.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + int exit = process.waitFor(); + if (exit != 0) { + throw new IOException("dwebp exited with status " + exit + (output.isEmpty() ? "" : ": " + output)); + } + try (InputStream pngStream = Files.newInputStream(tempFile)) { + BufferedImage converted = ImageIO.read(pngStream); + if (converted == null) { + throw new IOException("dwebp produced an unreadable PNG for " + imagePath); + } + return converted; + } + } catch (IOException err) { + String message = err.getMessage(); + if (message != null && message.contains("No such file or directory")) { + System.err.println("Warning: dwebp command not available. Install the 'webp' package to enable WebP decoding."); + return null; + } + throw err; + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while running dwebp for " + imagePath, err); + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException ignored) { + } + } + } + } + + private static boolean ensureWebpSupport() { + try { + ImageIO.setUseCache(false); + ImageIO.scanForPlugins(); + if (hasWebpReader()) { + return true; + } + Class spiClass = Class.forName("jdk.imageio.webp.WebPImageReaderSpi"); + Object instance = spiClass.getDeclaredConstructor().newInstance(); + if (instance instanceof ImageReaderSpi spi) { + IIORegistry registry = IIORegistry.getDefaultInstance(); + registry.registerServiceProvider(spi); + } + } catch (ClassNotFoundException err) { + return hasWebpReader(); + } catch (ReflectiveOperationException | LinkageError err) { + System.err.println("Warning: Unable to initialise WebP decoder: " + err.getMessage()); + return hasWebpReader(); + } + return hasWebpReader(); + } + + private static boolean hasWebpReader() { + return ImageIO.getImageReadersBySuffix("webp").hasNext() + || ImageIO.getImageReadersByMIMEType("image/webp").hasNext(); + } + private static void writeEntry(ZipOutputStream zos, String name, BufferedImage image) throws IOException { ZipEntry entry = new ZipEntry(name); zos.putNextEntry(entry); From f06e0aa3ce3b49c9447e5dea8e98121dc851a5c3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:21:43 +0200 Subject: [PATCH 9/9] Upload converted skin artifact in CI --- .github/workflows/avd-skin-conversion.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml index de56df5..beff71a 100644 --- a/.github/workflows/avd-skin-conversion.yml +++ b/.github/workflows/avd-skin-conversion.yml @@ -46,3 +46,9 @@ jobs: run: | test -f build/nexus_10.skin unzip -l build/nexus_10.skin + + - name: Upload converted skin artifact + uses: actions/upload-artifact@v4 + with: + name: nexus-10-codenameone-skin + path: build/nexus_10.skin