diff --git a/.github/workflows/avd-skin-conversion.yml b/.github/workflows/avd-skin-conversion.yml
new file mode 100644
index 0000000..beff71a
--- /dev/null
+++ b/.github/workflows/avd-skin-conversion.yml
@@ -0,0 +1,54 @@
+name: AVD Skin Conversion
+
+on:
+ pull_request:
+ paths:
+ - '.github/workflows/avd-skin-conversion.yml'
+ - 'AvdSkinToCodenameOneSkin.java'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ convert:
+ if: ${{ github.event_name != 'pull_request' || github.event.action != 'synchronize' || github.event.before != '0000000000000000000000000000000000000000' }}
+ 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: Install WebP conversion tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y webp
+
+ - name: Download sample AVD skin
+ run: |
+ set -euo pipefail
+ 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/nexus_10/*" -d skins
+
+ - name: Convert AVD skin to Codename One skin
+ run: |
+ mkdir -p build
+ java AvdSkinToCodenameOneSkin.java skins/Android-emulator-skins-master/nexus_10 build/nexus_10.skin
+
+ - name: Validate Codename One skin archive
+ 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
diff --git a/AvdSkinToCodenameOneSkin.java b/AvdSkinToCodenameOneSkin.java
new file mode 100644
index 0000000..018c36c
--- /dev/null
+++ b/AvdSkinToCodenameOneSkin.java
@@ -0,0 +1,797 @@
+import java.awt.*;
+import java.awt.image.BufferedImage;
+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;
+import java.util.*;
+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
+ * 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 {
+
+ static {
+ ensureWebpSupport();
+ }
+
+ 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));
+ }
+
+ Path layoutFile = findLayoutFile(skinDirectory);
+ LayoutInfo layoutInfo = LayoutInfo.parse(layoutFile, 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 = readImage(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 BufferedImage readImage(Path imagePath) throws IOException {
+ 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 {
+ 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 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);
+ 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, Path skinDirectory) {
+ try {
+ return new LayoutParser(layoutFile, skinDirectory).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<>();
+ 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;
+ this.layoutParent = layoutFile.getParent();
+ }
+
+ 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, this)
+ : null;
+ OrientationInfo landscape = builders.containsKey(OrientationType.LANDSCAPE)
+ ? builders.get(OrientationType.LANDSCAPE).build(OrientationType.LANDSCAPE, this)
+ : null;
+ return new LayoutInfo(portrait, landscape);
+ }
+
+ private void handleKeyValue(String line) {
+ Context ctx = contextStack.peek();
+ if (ctx == null) {
+ return;
+ }
+ OrientationType orientation = findCurrentOrientation();
+
+ 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 (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.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);
+ }
+ }
+ }
+
+ private boolean isImageKey(String key) {
+ String lower = key.toLowerCase(Locale.ROOT);
+ 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()) {
+ 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 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);
+ } catch (NumberFormatException err) {
+ throw new IllegalStateException("Invalid integer value '" + value + "' in layout file", err);
+ }
+ }
+
+ 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('#');
+ 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 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 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);
+ if (selectedImage == null || candidate.isBetterThan(selectedImage)) {
+ selectedImage = candidate;
+ }
+ }
+
+ OrientationInfo build(OrientationType type, LayoutParser parser) {
+ if (selectedImage == null) {
+ throw new IllegalStateException("Layout definition for " + type + " is incomplete");
+ }
+ 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));
+ }
+ }
+
+ 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") || lower.contains("shadow") || lower.contains("onion")) {
+ controlHint = true;
+ }
+ 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") || lowerName.contains("background") || lowerName.contains("back") || lowerName.contains("fore")) {
+ frameHint = true;
+ }
+ if (lowerName.contains("button") || lowerName.contains("control") || lowerName.contains("icon") || lowerName.contains("shadow") || lowerName.contains("onion")) {
+ 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;
+ }
+ }
+ }
+
+ 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;
+ }
+ }
+ }
+}