diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java index bd1eaee23..5e71aef75 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java @@ -17,6 +17,7 @@ package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi; import android.content.Context; +import android.graphics.Bitmap; import android.net.Uri; import org.slf4j.Logger; @@ -33,6 +34,7 @@ import java.util.regex.Pattern; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiBitmapUtils; import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; @@ -258,6 +260,63 @@ public class XiaomiFWHelper { return new String(localizationBytes, StandardCharsets.UTF_8); } + public Bitmap getWatchfacePreview() { + if (!isWatchface() || fw == null) { + return null; + } + + final ByteBuffer bb = ByteBuffer.wrap(fw).order(ByteOrder.LITTLE_ENDIAN); + final int previewOffset = bb.getInt(0x20); + if (previewOffset == 0) { + LOG.debug("No preview available (at offset 0)"); + return null; + } + + if (previewOffset + 12 > fw.length) { + LOG.debug("No preview available (header out-of-bounds)"); + return null; + } + + bb.position(previewOffset); + final int bitmapType = bb.get() & 0xff; + final int compressionType = bb.get() & 0xff; + bb.getShort(); // ignore + final int width = bb.getShort() & 0xffff; + final int height = bb.getShort() & 0xffff; + final int bitmapSize = bb.getInt(); + + byte[] bitmapData = new byte[bitmapSize]; + bb.get(bitmapData); + + if (compressionType != 0) { + LOG.debug("Preview image compression type: {}", compressionType); + switch (compressionType) { + case 4: + bitmapData = XiaomiBitmapUtils.decompressLvglRleV2(bitmapData); + break; + case 8: + bitmapData = XiaomiBitmapUtils.decompressLvglRleV1(bitmapData); + break; + default: + LOG.error("unknown compression type {}", compressionType); + return null; + } + + if (bitmapData == null) { + LOG.error("decompression returned null"); + return null; + } + } + + return XiaomiBitmapUtils.decodeWatchfaceImage( + bitmapData, + bitmapType, + compressionType == 8, + width, + height + ); + } + private boolean parseAsWatchface() { if (fw[0] != (byte) 0x5A || fw[1] != (byte) 0xA5) { LOG.warn("File header not a watchface"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java index eab30fabe..86f1e25d7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java @@ -17,6 +17,7 @@ package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi; import android.content.Context; +import android.graphics.Bitmap; import android.net.Uri; import nodomain.freeyourgadget.gadgetbridge.R; @@ -65,6 +66,11 @@ public class XiaomiInstallHandler implements InstallHandler { if (helper.isWatchface()) { installItem.setIcon(R.drawable.ic_watchface); installItem.setName(mContext.getString(R.string.kind_watchface)); + + final Bitmap preview = helper.getWatchfacePreview(); + if (preview != null) { + installItem.setPreview(preview); + } } else if (helper.isFirmware()) { installItem.setIcon(R.drawable.ic_firmware); installItem.setName(mContext.getString(R.string.kind_firmware)); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBitmapUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBitmapUtils.java index 14ddd4c69..727d3f61a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBitmapUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBitmapUtils.java @@ -26,9 +26,15 @@ import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; public class XiaomiBitmapUtils { private static final Logger LOG = LoggerFactory.getLogger(XiaomiBitmapUtils.class); + private static final byte[] LVGL_RLE_HEADER = new byte[] {(byte) 0xe0, 0x21, (byte) 0xa5, 0x5a }; public static final int PIXEL_FORMAT_RGB_565_LE = 0; public static final int PIXEL_FORMAT_RGB_565_BE = 1; @@ -167,4 +173,190 @@ public class XiaomiBitmapUtils { LOG.error("Unknown pixel format {}", pixelFormat); return null; } + + public static byte[] decompressLvglRleV1(final byte[] bitmapData) { + if (!ArrayUtils.equals(bitmapData, LVGL_RLE_HEADER, 0)) { + LOG.debug("Compressed data does not start with expected LVGL RLE header (found {})", + GB.hexdump(bitmapData, 0, 4)); + return null; + } + + int chunkSize = bitmapData[4] & 0xf; + if (chunkSize == 0) { + chunkSize = 1; + } + + final ByteBuffer bb = ByteBuffer.wrap(bitmapData).order(ByteOrder.LITTLE_ENDIAN); + bb.getInt(); // magic + final int decompressedSize = (bb.getInt() >> 4) & 0xfffffff; + + final byte[] out = new byte[decompressedSize]; + int outOff = 0; + + while (bb.hasRemaining()) { + byte control = bb.get(); + int n = control & 0x7f; + + if (outOff + chunkSize * (n+1) > out.length) { + LOG.error("decompression overflow"); + return null; + } + + if ((control & 0x80) != 0) { + // copy next chunk n+1 times to out + if (bb.remaining() < chunkSize) { + LOG.error("not enough data to decompress"); + return null; + } + + final byte[] chunk = new byte[chunkSize]; + bb.get(chunk); + + for (int i = 0; i < n + 1; i++) { + System.arraycopy(chunk, 0, out, outOff, chunk.length); + outOff += chunk.length; + } + } else { + // copy next n+1 chunks to out + if (bb.remaining() < chunkSize * (n + 1)) { + LOG.error("not enough data to decompress"); + return null; + } + + final byte[] chunk = new byte[chunkSize * (n+1)]; + bb.get(chunk); + System.arraycopy(chunk, 0, out, outOff, chunk.length); + outOff += chunk.length; + } + } + + return out; + } + + public static byte[] decompressLvglRleV2(final byte[] bitmapData) { + if (!ArrayUtils.equals(bitmapData, LVGL_RLE_HEADER, 0)) { + LOG.debug("Compressed data does not start with expected LVGL RLE header (found {})", + GB.hexdump(bitmapData, 0, 4)); + return null; + } + + int chunkSize = bitmapData[4] & 0xf; + if (chunkSize == 0) { + chunkSize = 1; + } + + LOG.debug("Chunk size: {}", chunkSize); + final ByteBuffer bb = ByteBuffer.wrap(bitmapData).order(ByteOrder.LITTLE_ENDIAN); + bb.getInt(); // magic + final int decompressedSize = (bb.getInt() >> 4) & 0xfffffff; + LOG.debug("Compressed size: {}, decompressed size: {}", bb.remaining(), decompressedSize); + + final byte[] out = new byte[decompressedSize]; + int outOff = 0; + + while (bb.hasRemaining()) { + byte control = bb.get(); + int n = control & 0x7f; + + if (outOff + chunkSize * n > out.length) { + LOG.error("decompression overflow"); + return null; + } + + if ((control & 0x80) != 0) { + // copy next n+1 chunks to out + if (bb.remaining() < chunkSize * n) { + LOG.error("not enough data to decompress"); + return null; + } + + final byte[] chunk = new byte[chunkSize * n]; + bb.get(chunk); + System.arraycopy(chunk, 0, out, outOff, chunk.length); + outOff += chunk.length; + } else { + // copy next chunk n+1 times to out + if (bb.remaining() < chunkSize) { + LOG.error("not enough data to decompress"); + return null; + } + + final byte[] chunk = new byte[chunkSize]; + bb.get(chunk); + + for (int i = 0; i < n; i++) { + System.arraycopy(chunk, 0, out, outOff, chunk.length); + outOff += chunk.length; + } + } + } + + return out; + } + + public static Bitmap decodeWatchfaceImage(final byte[] bitmapData, final int bitmapFormat, final boolean swapRedBlueChannel, final int width, final int height) { + final int expectedInputSize; + switch (bitmapFormat) { + case 0: + expectedInputSize = width * height * 4; + break; + case 1: + case 4: + case 7: + expectedInputSize = width * height * 2; + break; + case 16: + expectedInputSize = 256 * 4 + width * height; + break; + default: + LOG.warn("bitmap format {} unknown", bitmapFormat); + return null; + } + + if (expectedInputSize > bitmapData.length) { + LOG.error("Not enough pixel data (expected {} bytes, got {})", + expectedInputSize, + bitmapData.length); + return null; + } + + final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + final ByteBuffer bb = ByteBuffer.wrap(bitmapData); + bb.order(bitmapFormat == 7 ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN); + + + int[] palette = new int[0]; + if (bitmapFormat == 16) { + palette = new int[256]; + for (int i = 0; i < palette.length; i++) { + palette[i] = bb.getInt(); + } + } + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + switch (bitmapFormat) { + case 0x00: + bitmap.setPixel(x, y, bb.getInt()); + break; + case 0x01: + case 0x04: + case 0x07: + final int c565 = bb.getShort() & 0xffff; + final int pixel = 0xff000000 | + ((c565 & 0xf800) << 8) | + ((c565 & 0x07e0) << 5) | + ((c565 & 0x001f) << 3); + bitmap.setPixel(x, y, pixel); + break; + case 0x10: + final int paletteId = bb.get() & 0xff; + bitmap.setPixel(x, y, palette[paletteId]); + break; + } + } + } + + return bitmap; + } }