From 9174d9589408869a52d5c1b01b03b8a6b5323e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Sat, 23 Nov 2024 22:04:42 +0000 Subject: [PATCH] Zepp OS: Send notification pictures --- .../devices/huami/zeppos/ZeppOsSupport.java | 1 - .../operations/ZeppOsAgpsUpdateOperation.java | 2 +- .../ZeppOsGpxRouteUploadOperation.java | 1 + .../services/ZeppOsFileTransferService.java | 80 ++++-- .../services/ZeppOsNotificationService.java | 260 +++++++++++++++--- 5 files changed, 286 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java index 62695d282..426a35f56 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java @@ -152,7 +152,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; -import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.SilentMode; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsAgpsUpdateOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsAgpsUpdateOperation.java index 0d36bee16..38ff4fc68 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsAgpsUpdateOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsAgpsUpdateOperation.java @@ -111,7 +111,7 @@ public class ZeppOsAgpsUpdateOperation extends AbstractBTLEOperation 0) { + baos.write(buf, 0, read); + } + + return baos.toByteArray(); + } + public static byte[] decompress(final byte[] data) { final Inflater inflater = new Inflater(); final byte[] output = new byte[data.length]; @@ -276,16 +306,23 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { return output; } - public void sendFile(final String url, final String filename, final byte[] bytes, final Callback callback) { + public void sendFile(final String url, final String filename, final byte[] bytes, final boolean compress, final Callback callback) { if (mChunkSize < 0) { LOG.error("Service not initialized, refusing to send {}", url); callback.onFileUploadFinish(false); return; } - LOG.info("Sending {} bytes to {}", bytes.length, url); + LOG.info("Sending {} bytes to {} in {}", bytes.length, filename, url); - final FileTransferRequest request = new FileTransferRequest(url, filename, bytes, false, callback); + final FileTransferRequest request = new FileTransferRequest( + url, + filename, + bytes, + compress && mCompressedChunkSize > 0, + compress && mCompressedChunkSize > 0 ? mCompressedChunkSize : mChunkSize, + callback + ); if (mVersion == 3 && !mSessionRequests.isEmpty()) { // FIXME non-zero session on v3 @@ -302,6 +339,9 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { int payloadSize = 2 + url.length() + 1 + filename.length() + 1 + 4 + 4; if (mVersion == 3) { payloadSize += 2; + if (compress) { + payloadSize += 4; + } } final ByteBuffer buf = ByteBuffer.allocate(payloadSize); @@ -315,8 +355,10 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { buf.putInt(bytes.length); buf.putInt(request.getCrc32()); if (mVersion == 3) { - // compression ? - buf.put((byte) 0); + buf.put((byte) (compress ? 1 : 0)); + if (compress) { + buf.putInt(mCompressedChunkSize); + } buf.put((byte) 0); } @@ -333,7 +375,7 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { } if (request.getProgress() >= request.getSize()) { - LOG.info("Sending {} finished", request.getUrl()); + LOG.info("Finished sending {}", request.getUrl()); onUploadFinish(session, true); return; } @@ -354,7 +396,7 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { } private void writeChunkV1(final FileTransferRequest request, final byte session) { - final ByteBuffer buf = ByteBuffer.allocate(10 + mChunkSize); + final ByteBuffer buf = ByteBuffer.allocate(10 + request.getChunkSize()); buf.order(ByteOrder.LITTLE_ENDIAN); buf.put(CMD_DATA_SEND); @@ -362,7 +404,7 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { if (request.getProgress() == 0) { flags |= FLAG_FIRST_CHUNK; } - if (request.getProgress() + mChunkSize >= request.getSize()) { + if (request.getProgress() + request.getChunkSize() >= request.getSize()) { flags |= FLAG_LAST_CHUNK; } @@ -379,7 +421,7 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { final byte[] payload = ArrayUtils.subarray( request.getBytes(), request.getProgress(), - request.getProgress() + mChunkSize + request.getProgress() + request.getChunkSize() ); buf.putShort((short) payload.length); @@ -396,14 +438,14 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { final byte[] chunk = ArrayUtils.subarray( request.getBytes(), request.getProgress(), - request.getProgress() + mChunkSize + request.getProgress() + request.getChunkSize() ); byte flags = 0; if (request.getProgress() == 0) { flags |= FLAG_FIRST_CHUNK; } - if (request.getProgress() + mChunkSize >= request.getSize()) { + if (request.getProgress() + request.getChunkSize() >= request.getSize()) { flags |= FLAG_LAST_CHUNK; } @@ -494,16 +536,18 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { private final String filename; private final byte[] bytes; private final boolean compressed; + private final int chunkSize; private final Callback callback; private int progress = 0; private byte index = 0; private int crc32; - public FileTransferRequest(final String url, final String filename, final byte[] bytes, boolean compressed, final Callback callback) { + public FileTransferRequest(final String url, final String filename, final byte[] bytes, boolean compressed, int chunkSize, final Callback callback) { this.url = url; this.filename = filename; - this.bytes = bytes; + this.bytes = compressed ? compress(bytes) : bytes; this.compressed = compressed; + this.chunkSize = chunkSize; this.callback = callback; this.crc32 = CheckSums.getCRC32(bytes); } @@ -528,6 +572,10 @@ public class ZeppOsFileTransferService extends AbstractZeppOsService { return compressed; } + public int getChunkSize() { + return chunkSize; + } + public Callback getCallback() { return callback; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java index ce4363ef3..03f52e594 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java @@ -19,8 +19,11 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.servic import static org.apache.commons.lang3.ArrayUtils.subarray; import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.graphics.drawable.Drawable; +import androidx.annotation.Nullable; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +32,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.Locale; +import java.util.function.Consumer; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; @@ -42,6 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil; +import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; @@ -51,12 +56,16 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { public static final short ENDPOINT = 0x001e; + public static final byte NOTIFICATION_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte NOTIFICATION_CMD_CAPABILITIES_RESPONSE = 0x02; public static final byte NOTIFICATION_CMD_SEND = 0x03; public static final byte NOTIFICATION_CMD_REPLY = 0x04; public static final byte NOTIFICATION_CMD_DISMISS = 0x05; public static final byte NOTIFICATION_CMD_REPLY_ACK = 0x06; public static final byte NOTIFICATION_CMD_ICON_REQUEST = 0x10; public static final byte NOTIFICATION_CMD_ICON_REQUEST_ACK = 0x11; + public static final byte NOTIFICATION_CMD_PICTURE_REQUEST = 0x19; + public static final byte NOTIFICATION_CMD_PICTURE_REQUEST_ACK = 0x1a; public static final byte NOTIFICATION_TYPE_NORMAL = (byte) 0xfa; public static final byte NOTIFICATION_TYPE_CALL = 0x03; public static final byte NOTIFICATION_TYPE_SMS = (byte) 0x05; @@ -68,10 +77,17 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { public static final byte NOTIFICATION_CALL_STATE_START = 0x00; public static final byte NOTIFICATION_CALL_STATE_END = 0x02; + private int version = -1; + private boolean supportsPictures = false; + private boolean supportsNotificationKey = false; + // Keep track of Notification ID -> action handle, as BangleJSDeviceSupport. // This needs to be simplified. private final LimitedQueue mNotificationReplyAction = new LimitedQueue<>(16); + // Keep track of notification pictures + private final LimitedQueue mNotificationPictures = new LimitedQueue<>(16); + private final ZeppOsFileTransferService fileTransferService; public ZeppOsNotificationService(final ZeppOsSupport support, final ZeppOsFileTransferService fileTransferService) { @@ -84,13 +100,45 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { return ENDPOINT; } + @Override + public void initialize(final TransactionBuilder builder) { + requestCapabilities(builder); + } + + public void requestCapabilities(final TransactionBuilder builder) { + write(builder, NOTIFICATION_CMD_CAPABILITIES_REQUEST); + } + @Override public void handlePayload(final byte[] payload) { + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + final byte cmd = buf.get(); + final GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl(); final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl(); - switch (payload[0]) { - case NOTIFICATION_CMD_REPLY: + switch (cmd) { + case NOTIFICATION_CMD_CAPABILITIES_RESPONSE: { + version = buf.get() & 0xff; + if (version < 4 || version > 5) { + // Untested, might work, might not.. + LOG.warn("Unsupported notification service version {}", version); + } + if (version >= 4) { + final short unk1 = buf.getShort(); // 100 + final byte unk2 = buf.get(); // 1 + final byte unk3 = buf.get(); // 1 + final short unk4count = buf.getShort(); + buf.get(new byte[unk4count]); + } + if (version >= 5) { + supportsPictures = buf.get() != 0; + supportsNotificationKey = buf.get() != 0; + } + LOG.info("Notification service version={}, supportsPictures={}", version, supportsPictures); + break; + } + case NOTIFICATION_CMD_REPLY: { // TODO make this configurable? final int notificationId = BLETypeConversions.toUint32(subarray(payload, 1, 5)); final Long replyHandle = mNotificationReplyAction.lookup(notificationId); @@ -114,6 +162,7 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { ackNotificationReply(notificationId); // FIXME: premature? deleteNotification(notificationId); // FIXME: premature? return; + } case NOTIFICATION_CMD_DISMISS: switch (payload[1]) { case NOTIFICATION_DISMISS_NOTIFICATION: @@ -138,7 +187,7 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { LOG.warn("Unexpected notification dismiss byte {}", String.format("0x%02x", payload[1])); return; } - case NOTIFICATION_CMD_ICON_REQUEST: + case NOTIFICATION_CMD_ICON_REQUEST: { final String packageName = StringUtils.untilNullTerminator(payload, 1); if (packageName == null) { LOG.error("Failed to decode package name from payload"); @@ -159,6 +208,31 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { int height = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); sendIconForPackage(packageName, iconFormat, width, height); return; + } + case NOTIFICATION_CMD_PICTURE_REQUEST: { + final String packageName = StringUtils.untilNullTerminator(buf); + if (packageName == null) { + LOG.error("Failed to decode package name for picture from payload"); + return; + } + + final int notificationId = buf.getInt(); + final byte pictureFormat = buf.get(); + final int width = buf.getShort(); + final int height = buf.getShort(); + + LOG.info( + "Got notification picture request for {}, {}, {}, {}x{}", + packageName, + notificationId, + pictureFormat, + width, + height + ); + + sendNotificationPicture(packageName, notificationId, pictureFormat, width); + return; + } default: LOG.warn("Unexpected notification byte {}", String.format("0x%02x", payload[0])); } @@ -264,7 +338,7 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { // reply boolean hasReply = false; - if (notificationSpec.attachedActions != null && notificationSpec.attachedActions.size() > 0) { + if (notificationSpec.attachedActions != null && !notificationSpec.attachedActions.isEmpty()) { for (int i = 0; i < notificationSpec.attachedActions.size(); i++) { final NotificationSpec.Action action = notificationSpec.attachedActions.get(i); @@ -281,6 +355,19 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { } baos.write((byte) (hasReply ? 1 : 0)); + if (version >= 5) { + baos.write(1); // ? + } + if (supportsPictures) { + baos.write((byte) (notificationSpec.picturePath != null ? 1 : 0)); + if (notificationSpec.picturePath != null) { + mNotificationPictures.add(notificationSpec.getId(), notificationSpec.picturePath); + } + } + if (supportsNotificationKey) { + baos.write(notificationSpec.key.getBytes(StandardCharsets.UTF_8)); + baos.write(0); + } write(builder, baos.toByteArray()); builder.queue(getSupport().getQueue()); @@ -325,7 +412,11 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { write("ack notification reply", buf.array()); } - private void ackNotificationAfterIconSent(final String queuedIconPackage) { + private void ackNotificationAfterIconSent(final String queuedIconPackage, final boolean success) { + if (!success) { + return; + } + LOG.info("Acknowledging icon send for {}", queuedIconPackage); final ByteBuffer buf = ByteBuffer.allocate(1 + queuedIconPackage.length() + 1 + 1); @@ -333,33 +424,29 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { buf.put(NOTIFICATION_CMD_ICON_REQUEST_ACK); buf.put(queuedIconPackage.getBytes(StandardCharsets.UTF_8)); buf.put((byte) 0x00); - buf.put((byte) 0x01); + buf.put((byte) 0x01); // TODO !success? write("ack icon send", buf.array()); } - private void sendIconForPackage(final String packageName, final byte iconFormat, final int width, final int height) { - if (getSupport().getMTU() < 247) { - LOG.warn("Sending icons requires high MTU, current MTU is {}", getSupport().getMTU()); - return; - } + private void ackNotificationAfterPictureSent(final String packageName, final int notificationId, final boolean success) { + LOG.info("Acknowledging picture send for {}", packageName); - // Without the expected tga id and format string they seem to get corrupted, - // but the encoding seems to actually be the same...? - final String format; - final String tgaId; - switch (iconFormat) { - case 0x04: - format = "TGA_RGB565_GCNANOLITE"; - tgaId = "SOMHP"; - break; - case 0x08: - format = "TGA_RGB565_DAVE2D"; - tgaId = "SOMH6"; - break; - default: - LOG.error("Unknown icon format {}", String.format("0x%02x", iconFormat)); - return; + final ByteBuffer buf = ByteBuffer.allocate(1 + packageName.length() + 1 + 4 + 1).order(ByteOrder.LITTLE_ENDIAN); + buf.put(NOTIFICATION_CMD_PICTURE_REQUEST_ACK); + buf.put(packageName.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0x00); + buf.putInt(notificationId); + buf.put((byte) (success ? 0x01 : 0x02)); + + write("ack picture send", buf.array()); + } + + private void sendIconForPackage(final String packageName, final byte iconFormat, final int width, final int height) { + final BitmapFormat format = BitmapFormat.fromCode(iconFormat); + if (format == null) { + LOG.error("Unknown icon bitmap format code {}", String.format("0x%02x", iconFormat)); + return; } final Drawable icon = NotificationUtils.getAppIcon(getContext(), packageName); @@ -369,12 +456,7 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { } final Bitmap bmp = BitmapUtil.toBitmap(icon); - - // The TGA needs to have this ID, or the band does not accept it - final byte[] tgaIdBytes = new byte[46]; - System.arraycopy(tgaId.getBytes(StandardCharsets.UTF_8), 0, tgaIdBytes, 0, 5); - - final byte[] tga565 = BitmapUtil.convertToTgaRGB565(bmp, width, height, tgaIdBytes); + final byte[] tga = encodeBitmap(bmp, format, width, height); final String url = String.format( Locale.ROOT, @@ -386,22 +468,76 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { ); final String filename = String.format("logo_%s.tga", packageName.replace(".", "_")); + sendFile(url, filename, tga, false, success -> ackNotificationAfterIconSent(packageName, success)); + } + + private void sendNotificationPicture(final String packageName, final int notificationId, final byte pictureFormat, final int width) { + final BitmapFormat format = BitmapFormat.fromCode(pictureFormat); + if (format == null) { + LOG.error("Unknown picture bitmap format code {}", String.format("0x%02x", pictureFormat)); + ackNotificationAfterPictureSent(packageName, notificationId, false); + return; + } + + final String picturePath = mNotificationPictures.lookup(notificationId); + if (picturePath == null) { + LOG.warn("Failed to find picture path for {}", notificationId); + ackNotificationAfterPictureSent(packageName, notificationId, false); + return; + } + + final Bitmap bmp = BitmapFactory.decodeFile(picturePath); + if (bmp == null) { + LOG.warn("Failed to decode bitmap from {}", picturePath); + ackNotificationAfterPictureSent(packageName, notificationId, false); + return; + } + + // FIXME: On the GTR 4, the band sends 358 on the url, but the actual image has 368 width + // if sent as requested, it gets all corrupted... + final int targetWidth = width + 10; + final int targetHeight = (int) Math.round(bmp.getHeight() * ((double) targetWidth / bmp.getWidth())); + final byte[] tga = encodeBitmap(bmp, format, targetWidth, targetHeight); + + final String url = String.format( + Locale.ROOT, + "notification://content_image?app_id=%s&uid=%d&width=%d&height=%d&format=%s", + packageName, + notificationId, + width, + targetHeight, + format + ); + final String filename = String.format(Locale.ROOT, "picture_%d.tga", notificationId); + + sendFile(url, filename, tga, true, success -> ackNotificationAfterPictureSent(packageName, notificationId, success)); + } + + private void sendFile(final String url, + final String filename, + final byte[] bytes, + final boolean compress, + final Consumer uploadFinishCallback) { + if (getSupport().getMTU() < 247) { + LOG.warn("Sending files requires high MTU, current MTU is {}", getSupport().getMTU()); + return; + } + fileTransferService.sendFile( url, filename, - tga565, + bytes, + true, new ZeppOsFileTransferService.Callback() { @Override public void onFileUploadFinish(final boolean success) { - LOG.info("Finished sending icon, success={}", success); - if (success) { - ackNotificationAfterIconSent(packageName); - } + LOG.info("Finished sending '{}' to '{}', success={}", filename, url, success); + uploadFinishCallback.accept(success); } @Override public void onFileUploadProgress(final int progress) { - LOG.trace("Icon send progress: {}", progress); + LOG.trace("File send progress: {}", progress); } @Override @@ -411,6 +547,50 @@ public class ZeppOsNotificationService extends AbstractZeppOsService { } ); - LOG.info("Queueing icon for {}", packageName); + LOG.info("Queueing file send '{}' to '{}'", filename, url); + } + + private static byte[] encodeBitmap(final Bitmap bmp, final BitmapFormat format, final int width, final int height) { + // Without the expected tga id and format string they seem to get corrupted, + // but the encoding seems to actually be the same...? + // The TGA needs to have this ID, or the band does not accept it + final byte[] tgaIdBytes = new byte[46]; + //System.arraycopy(format.getTgaId().getBytes(StandardCharsets.UTF_8), 0, tgaIdBytes, 0, 5); + System.arraycopy(GB.hexStringToByteArray("534F4D486601"), 0, tgaIdBytes, 0, 6); + + return BitmapUtil.convertToTgaRGB565(bmp, width, height, tgaIdBytes); + } + + private enum BitmapFormat { + TGA_RGB565_GCNANOLITE(0x04, "SOMHP"), + TGA_RGB565_DAVE2D(0x08, "SOMH6"), + ; + + private final byte code; + private final String tgaId; + + BitmapFormat(final int code, final String tgaId) { + this.code = (byte) code; + this.tgaId = tgaId; + } + + public byte getCode() { + return code; + } + + public String getTgaId() { + return tgaId; + } + + @Nullable + public static BitmapFormat fromCode(final byte code) { + for (final BitmapFormat format : BitmapFormat.values()) { + if (format.code == code) { + return format; + } + } + + return null; + } } }