/* 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);
}
}