1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-11-24 02:46:50 +01:00

Xiaomi: extract watch face preview image

This commit is contained in:
MrYoranimo 2024-09-26 03:06:18 +02:00 committed by José Rebelo
parent b2cf83d002
commit 0a9da03618
3 changed files with 257 additions and 0 deletions

View File

@ -17,6 +17,7 @@
package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi; package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -33,6 +34,7 @@ import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; 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.ArrayUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
@ -258,6 +260,63 @@ public class XiaomiFWHelper {
return new String(localizationBytes, StandardCharsets.UTF_8); 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() { private boolean parseAsWatchface() {
if (fw[0] != (byte) 0x5A || fw[1] != (byte) 0xA5) { if (fw[0] != (byte) 0x5A || fw[1] != (byte) 0xA5) {
LOG.warn("File header not a watchface"); LOG.warn("File header not a watchface");

View File

@ -17,6 +17,7 @@
package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi; package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.R;
@ -65,6 +66,11 @@ public class XiaomiInstallHandler implements InstallHandler {
if (helper.isWatchface()) { if (helper.isWatchface()) {
installItem.setIcon(R.drawable.ic_watchface); installItem.setIcon(R.drawable.ic_watchface);
installItem.setName(mContext.getString(R.string.kind_watchface)); installItem.setName(mContext.getString(R.string.kind_watchface));
final Bitmap preview = helper.getWatchfacePreview();
if (preview != null) {
installItem.setPreview(preview);
}
} else if (helper.isFirmware()) { } else if (helper.isFirmware()) {
installItem.setIcon(R.drawable.ic_firmware); installItem.setIcon(R.drawable.ic_firmware);
installItem.setName(mContext.getString(R.string.kind_firmware)); installItem.setName(mContext.getString(R.string.kind_firmware));

View File

@ -26,9 +26,15 @@ import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; 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 { public class XiaomiBitmapUtils {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiBitmapUtils.class); 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_LE = 0;
public static final int PIXEL_FORMAT_RGB_565_BE = 1; public static final int PIXEL_FORMAT_RGB_565_BE = 1;
@ -167,4 +173,190 @@ public class XiaomiBitmapUtils {
LOG.error("Unknown pixel format {}", pixelFormat); LOG.error("Unknown pixel format {}", pixelFormat);
return null; 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;
}
} }