/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services; import android.Manifest; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.text.SimpleDateFormat; import java.util.Date; import java.util.LinkedList; import java.util.Locale; import java.util.Queue; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil; import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiBitmapUtils.convertToPixelFormat; import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiBitmapUtils.getPixelFormatString; public class XiaomiNotificationService extends AbstractXiaomiService implements XiaomiDataUploadService.Callback { private static final Logger LOG = LoggerFactory.getLogger(XiaomiNotificationService.class); private static final SimpleDateFormat TIMESTAMP_SDF = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ROOT); public static final int COMMAND_TYPE = 7; public static final int CMD_NOTIFICATION_SEND = 0; public static final int CMD_NOTIFICATION_DISMISS = 1; public static final int CMD_CALL_REJECT = 2; public static final int CMD_CALL_IGNORE = 5; public static final int CMD_SCREEN_ON_ON_NOTIFICATIONS_GET = 6; public static final int CMD_SCREEN_ON_ON_NOTIFICATIONS_SET = 7; public static final int CMD_OPEN_ON_PHONE = 8; public static final int CMD_CANNED_MESSAGES_GET = 9; public static final int CMD_CANNED_MESSAGES_SET = 12; // also canned message reply public static final int CMD_CALL_REPLY_SEND = 13; public static final int CMD_CALL_REPLY_ACK = 14; public static final int CMD_NOTIFICATION_ICON_REQUEST = 15; public static final int CMD_NOTIFICATION_ICON_QUERY = 16; // Maintain a queue of the last seen package names, since the band will send // requests with the package name truncated, and without an ID private final Queue mPackages = new LinkedList<>(); // Keep track of package names and keys for notification dismissal private final LimitedQueue mNotificationPackageName = new LimitedQueue<>(128); private final LimitedQueue mNotificationKey = new LimitedQueue<>(128); private String iconPackageName; public XiaomiNotificationService(final XiaomiSupport support) { super(support); } @Override public void initialize() { getSupport().sendCommand("get screen on on notifications", COMMAND_TYPE, CMD_SCREEN_ON_ON_NOTIFICATIONS_GET); requestCannedMessages(); } @Override public void handleCommand(final XiaomiProto.Command cmd) { final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl(); final GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl(); switch (cmd.getSubtype()) { case CMD_NOTIFICATION_DISMISS: LOG.info("Watch dismiss {} notifications", cmd.getNotification().getNotificationDismiss().getNotificationIdCount()); for (final XiaomiProto.NotificationId notificationId : cmd.getNotification().getNotificationDismiss().getNotificationIdList()) { LOG.debug("Watch dismiss {}", notificationId.getId()); deviceEvtNotificationControl.handle = notificationId.getId(); deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS; getSupport().evaluateGBDeviceEvent(deviceEvtNotificationControl); } return; case CMD_CALL_REJECT: LOG.debug("Reject call"); deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT; getSupport().evaluateGBDeviceEvent(deviceEvtCallControl); return; case CMD_CALL_IGNORE: LOG.debug("Ignore call"); deviceEvtCallControl.event = GBDeviceEventCallControl.Event.IGNORE; getSupport().evaluateGBDeviceEvent(deviceEvtCallControl); return; case CMD_SCREEN_ON_ON_NOTIFICATIONS_GET: final boolean screenOnOnNotifications = cmd.getNotification().getScreenOnOnNotifications(); LOG.debug("Got screen on on notifications: {}", screenOnOnNotifications); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences( DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS, screenOnOnNotifications ).withPreference( XiaomiPreferences.FEAT_SCREEN_ON_ON_NOTIFICATIONS, true ); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); return; case CMD_OPEN_ON_PHONE: LOG.debug("Open on phone {}", cmd.getNotification().getOpenOnPhone().getId()); deviceEvtNotificationControl.handle = cmd.getNotification().getOpenOnPhone().getId(); deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.OPEN; getSupport().evaluateGBDeviceEvent(deviceEvtNotificationControl); case CMD_CANNED_MESSAGES_GET: handleCannedMessages(cmd.getNotification().getCannedMessages()); return; case CMD_CALL_REPLY_SEND: handleCannedSmsReply(cmd.getNotification().getNotificationReply()); return; case CMD_NOTIFICATION_ICON_REQUEST: handleNotificationIconRequest(cmd.getNotification().getNotificationIconRequest()); return; case CMD_NOTIFICATION_ICON_QUERY: handleNotificationIconQuery(cmd.getNotification().getNotificationIconQuery()); return; } LOG.warn("Unhandled notification command {}", cmd.getSubtype()); } @Override public boolean onSendConfiguration(final String config, final Prefs prefs) { switch (config) { case DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS: setScreenOnOnNotifications(); return true; } return super.onSendConfiguration(config, prefs); } public void onNotification(final NotificationSpec notificationSpec) { if (!getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS, true)) { LOG.debug("App notifications disabled - ignoring"); return; } final XiaomiProto.Notification3.Builder notification3 = XiaomiProto.Notification3.newBuilder() .setId(notificationSpec.getId()) .setUnknown4("") // ? .setTimestamp(TIMESTAMP_SDF.format(new Date(notificationSpec.when))); if (notificationSpec.sourceAppId != null) { notification3.setPackage(notificationSpec.sourceAppId); } else { notification3.setPackage(BuildConfig.APPLICATION_ID); } mNotificationPackageName.add(notificationSpec.getId(), notificationSpec.sourceAppId); mNotificationKey.add(notificationSpec.getId(), notificationSpec.key); mPackages.add(notification3.getPackage()); if (mPackages.size() > 32) { mPackages.poll(); } final String senderOrTitle = StringUtils.getFirstOf(notificationSpec.sender, notificationSpec.title); if (!senderOrTitle.isEmpty()) { notification3.setTitle(senderOrTitle); } if (notificationSpec.body != null) { notification3.setBody(notificationSpec.body); } if (notificationSpec.sourceName != null) { notification3.setAppName(notificationSpec.sourceName); } else { // Should never happen, but notification is not shown otherwise notification3.setAppName("UNKNOWN"); } if (notificationSpec.key != null) { notification3.setKey(notificationSpec.key); notification3.setOpenOnPhone(true); } final XiaomiProto.Notification2 notification2 = XiaomiProto.Notification2.newBuilder() .setNotification3(notification3) .build(); final XiaomiProto.Notification notification = XiaomiProto.Notification.newBuilder() .setNotification2(notification2) .build(); getSupport().sendCommand( "send notification " + notificationSpec.getId(), XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_NOTIFICATION_SEND) .setNotification(notification) .build() ); } public void onDeleteNotification(final int id) { if (!getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS, true)) { LOG.debug("App notifications disabled - ignoring delete"); return; } if (mNotificationPackageName.lookup(id) == null) { LOG.warn("Unable to lookup notification {} to delete", id); return; } final XiaomiProto.NotificationId notificationId = XiaomiProto.NotificationId.newBuilder() .setId(id) .setPackage(mNotificationPackageName.lookup(id)) .setKey(mNotificationKey.lookup(id)) .build(); final XiaomiProto.NotificationDismiss notificationDismiss = XiaomiProto.NotificationDismiss.newBuilder() .addNotificationId(notificationId) .build(); final XiaomiProto.Notification notification = XiaomiProto.Notification.newBuilder() .setNotificationDismiss(notificationDismiss) .build(); getSupport().sendCommand( "delete notification " + id, XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_NOTIFICATION_DISMISS) .setNotification(notification) .build() ); } public void onSetCallState(final CallSpec callSpec) { if (callSpec.command == CallSpec.CALL_OUTGOING) { return; } if (callSpec.command != CallSpec.CALL_INCOMING) { final XiaomiProto.NotificationDismiss.Builder notification4 = XiaomiProto.NotificationDismiss.newBuilder() .addNotificationId(XiaomiProto.NotificationId.newBuilder().setId(0).setPackage("phone")); final XiaomiProto.Notification notification = XiaomiProto.Notification.newBuilder() .setNotificationDismiss(notification4) .build(); getSupport().sendCommand( "send call end", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_NOTIFICATION_DISMISS) .setNotification(notification) .build() ); return; } final XiaomiProto.Notification3.Builder notification3 = XiaomiProto.Notification3.newBuilder() .setId(0) // ? .setUnknown4("") // ? .setIsCall(true) .setRepliesAllowed(canSendSms()) .setTimestamp(TIMESTAMP_SDF.format(new Date())); notification3.setPackage("phone"); notification3.setAppName("phone"); if (callSpec.name != null) { notification3.setTitle(callSpec.name); } else { notification3.setTitle("?"); } if (callSpec.number != null) { notification3.setBody(callSpec.number); } else { notification3.setBody("?"); } final XiaomiProto.Notification2 notification2 = XiaomiProto.Notification2.newBuilder() .setNotification3(notification3) .build(); final XiaomiProto.Notification notification = XiaomiProto.Notification.newBuilder() .setNotification2(notification2) .build(); getSupport().sendCommand( "send call", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_NOTIFICATION_SEND) .setNotification(notification) .build() ); } private void setScreenOnOnNotifications() { final Prefs prefs = getDevicePrefs(); final boolean screenOnOnNotificationsEnabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS, true); LOG.info("Setting screen on on notification: {}", screenOnOnNotificationsEnabled); getSupport().sendCommand( "set screen on on notification", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_SCREEN_ON_ON_NOTIFICATIONS_SET) .setNotification(XiaomiProto.Notification.newBuilder().setScreenOnOnNotifications(screenOnOnNotificationsEnabled).build()) .build() ); } public void onSetCannedMessages(final CannedMessagesSpec cannedMessagesSpec) { if (cannedMessagesSpec.type != CannedMessagesSpec.TYPE_REJECTEDCALLS) { LOG.warn("Got unsupported canned messages type: {}", cannedMessagesSpec.type); return; } final int minReplies = getDevicePrefs().getInt(XiaomiPreferences.PREF_CANNED_MESSAGES_MIN, 0); final int maxReplies = getDevicePrefs().getInt(XiaomiPreferences.PREF_CANNED_MESSAGES_MAX, 0); if (maxReplies == 0) { LOG.warn("Attempting to set canned messages, but max replies is 0"); return; } final XiaomiProto.CannedMessages.Builder cannedMessagesBuilder = XiaomiProto.CannedMessages.newBuilder() .setMinReplies(minReplies) .setMaxReplies(maxReplies); int i = 0; for (final String cannedMessage : cannedMessagesSpec.cannedMessages) { if (i >= maxReplies) { LOG.warn("Got too many canned messages ({}), limit is {}", cannedMessagesSpec.cannedMessages.length, maxReplies); break; } cannedMessagesBuilder.addReply(cannedMessage); i++; } for (; i < minReplies; i++) { cannedMessagesBuilder.addReply("-"); } final XiaomiProto.Notification.Builder notificationBuilder = XiaomiProto.Notification.newBuilder() .setCannedMessages(cannedMessagesBuilder); getSupport().sendCommand( "set canned messages", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CANNED_MESSAGES_SET) .setNotification(notificationBuilder) .build() ); } public void requestCannedMessages() { getSupport().sendCommand( "get canned messages", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CANNED_MESSAGES_GET) .setNotification(XiaomiProto.Notification.newBuilder().setUnknown8(1)) .build() ); } public void handleCannedMessages(final XiaomiProto.CannedMessages cannedMessages) { LOG.info("Got {} canned messages", cannedMessages.getReplyCount()); final int minReplies = cannedMessages.getMinReplies(); final int maxReplies = cannedMessages.getMaxReplies(); final GBDeviceEventUpdatePreferences gbDeviceEventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.PREF_CANNED_MESSAGES_MIN, minReplies) .withPreference(XiaomiPreferences.PREF_CANNED_MESSAGES_MAX, maxReplies); int i = 1; for (final String reply : cannedMessages.getReplyList()) { gbDeviceEventUpdatePreferences.withPreference("canned_message_dismisscall_" + i, reply); i++; if (i > maxReplies || i > 16) { LOG.warn("Got too many canned messages ({})", i); break; } } for (int j = i; j <= maxReplies; j++) { gbDeviceEventUpdatePreferences.withPreference("canned_message_dismisscall_" + j, null); } getSupport().evaluateGBDeviceEvent(gbDeviceEventUpdatePreferences); } public boolean canSendSms() { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { return getSupport().getContext().checkSelfPermission(Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED; } else { return true; } } private void handleNotificationIconQuery(final XiaomiProto.NotificationIconPackage notificationIconPackage) { LOG.debug("Watch querying notification icon for {}", notificationIconPackage.getPackage()); iconPackageName = notificationIconPackage.getPackage(); if (NotificationUtils.getAppIcon(getSupport().getContext(), iconPackageName) == null) { // Attempt to find truncated package name for (final String fullPackage : mPackages) { if (fullPackage.startsWith(iconPackageName)) { iconPackageName = fullPackage; break; } } if (iconPackageName.equals(notificationIconPackage.getPackage())) { LOG.warn("Failed to match a full package for {}", iconPackageName); return; } if (NotificationUtils.getAppIcon(getSupport().getContext(), iconPackageName) == null) { LOG.warn("Failed to find icon for {} and {}", notificationIconPackage.getPackage(), iconPackageName); return; } } getSupport().sendCommand( "send notification reply for " + iconPackageName, XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_NOTIFICATION_ICON_REQUEST) .setNotification(XiaomiProto.Notification.newBuilder() .setNotificationIconReply(notificationIconPackage) ).build() ); } private void handleCannedSmsReply(final XiaomiProto.NotificationReply notificationReply) { final String phoneNumber = notificationReply.getNumber(); if (phoneNumber == null) { LOG.warn("Missing phone number for sms reply"); ackSmsReply(false); return; } final String message = notificationReply.getMessage(); if (message == null) { LOG.warn("Missing message for sms reply"); ackSmsReply(false); return; } LOG.debug("Sending SMS message '{}' to number '{}' and rejecting call", message, phoneNumber); final GBDeviceEventNotificationControl devEvtNotificationControl = new GBDeviceEventNotificationControl(); devEvtNotificationControl.handle = -1; devEvtNotificationControl.phoneNumber = phoneNumber; devEvtNotificationControl.reply = message; devEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY; getSupport().evaluateGBDeviceEvent(devEvtNotificationControl); final GBDeviceEventCallControl rejectCallCmd = new GBDeviceEventCallControl(GBDeviceEventCallControl.Event.REJECT); getSupport().evaluateGBDeviceEvent(rejectCallCmd); // FIXME probably premature ackSmsReply(true); } private void ackSmsReply(final boolean success) { getSupport().sendCommand( "ack sms reply success=" + success, XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CALL_REPLY_ACK) .setNotification(XiaomiProto.Notification.newBuilder() .setNotificationReplyStatus(success ? 0 : 1) ).build() ); } private void handleNotificationIconRequest(final XiaomiProto.NotificationIconRequest notificationIconRequest) { if (iconPackageName == null) { LOG.warn("No icon package name"); return; } final int status = notificationIconRequest.getStatus(); final int pixelFormat = notificationIconRequest.getPixelFormat(); final int size = notificationIconRequest.getSize(); LOG.debug("Got notification icon request for size {} for {}, status={}, pixelFormat={} ({})", size, iconPackageName, status, getPixelFormatString(pixelFormat), pixelFormat); if (status != 0) { return; } final Drawable icon = NotificationUtils.getAppIcon(getSupport().getContext(), iconPackageName); if (icon == null) { // FIXME the packageName is sometimes truncated LOG.warn("Failed to get icon for {}", iconPackageName); return; } final byte[] bitmap = convertToPixelFormat(pixelFormat, icon, size, size); if (bitmap == null || bitmap.length == 0) { LOG.error("Failed to convert app icon"); return; } getSupport().getDataUploadService().setCallback(this); getSupport().getDataUploadService().requestUpload(XiaomiDataUploadService.TYPE_NOTIFICATION_ICON, bitmap); } @Override public void onUploadFinish(final boolean success) { LOG.debug("Notification icon upload finished: {}", success); getSupport().getConnectionSpecificSupport().runOnQueue( "notification icon upload finish", () -> getSupport().getDataUploadService().setCallback(null) ); } @Override public void onUploadProgress(final int progressPercent) { LOG.debug("Notification icon upload progress: {}", progressPercent); } }