Compare commits

..

7 Commits

Author SHA1 Message Date
José Rebelo
98cc6c5497 Fix custom actions on sdk >= 34 2025-01-09 19:22:54 +00:00
José Rebelo
965e22bf34 Garmin: Add custom notification actions 2025-01-09 18:55:17 +00:00
José Rebelo
7893ace986 Set default date of birth to 2000-01-01 2025-01-09 18:08:15 +00:00
José Rebelo
c0883de546 Garmin Forerunner 45: Initial support 2025-01-06 18:05:53 +00:00
Me7c7
1a21f01071 Huawei: UTF-16 encoding support for reply. 2025-01-06 14:06:57 +02:00
Me7c7
b1cccae3ac Huawei: fix SMS reply from the watch 2025-01-06 13:21:21 +02:00
Arjan Schrijver
790e81a6f6 Add disabling battery optimizations to permissions screen 2025-01-06 09:18:18 +01:00
12 changed files with 160 additions and 22 deletions

View File

@ -38,6 +38,13 @@
<!-- Take wake locks (e.g. for time sync) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--
Necessary for asking the user to disable battery optimizations.
GB falls under the acceptable use cases documented here:
https://developer.android.com/training/monitoring-device-state/doze-standby.html#exemption-cases
-->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Read loyalty cards from Catima -->
<uses-permission android:name="me.hackerchick.catima.READ_CARDS"/>
<uses-permission android:name="me.hackerchick.catima.debug.READ_CARDS"/>

View File

@ -0,0 +1,34 @@
/* Copyright (C) 2024 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminForerunner45Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("^Forerunner 45$");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_forerunner_45;
}
}

View File

@ -19,6 +19,8 @@ package nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets;
import android.text.TextUtils;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
@ -376,13 +378,12 @@ public class Notifications {
public int type = 0;
public int encoding = 0; // 3 - "utf-16"
public int subId = 0;
public String sender;
public String key;
public String addData;
public String text;
public ReplyResponse(ParamsProvider paramsProvider) {
super(paramsProvider);
this.serviceId = Notifications.id;
this.commandId = id;
}
@ -398,13 +399,17 @@ public class Notifications {
if (this.tlv.contains(0x04))
this.key = this.tlv.getString(0x04);
if (this.tlv.contains(0x05))
this.sender = this.tlv.getString(0x05);
if (this.tlv.contains(0x06))
this.text = this.tlv.getString(0x06);
this.addData = this.tlv.getString(0x05);
if (this.tlv.contains(0x06)) {
if(this.encoding == 3) {
this.text = new String(this.tlv.getBytes(0x06), StandardCharsets.UTF_16);
} else {
this.text = this.tlv.getString(0x06);
}
}
}
}
//TODO: send ack if required, 7f on error.
public static class ReplyAck extends HuaweiPacket {
public ReplyAck(

View File

@ -22,6 +22,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents;
import android.app.ActivityOptions;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
@ -243,7 +244,14 @@ public class NotificationListener extends NotificationListenerService {
RemoteInput.addResultsToIntent(new RemoteInput[]{remoteInput}, localIntent, extras);
actionIntent.send(context, 0, localIntent);
} else {
actionIntent.send();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
final ActivityOptions activityOptions = ActivityOptions.makeBasic();
final Bundle bundle = activityOptions.setPendingIntentBackgroundActivityStartMode(ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED)
.toBundle();
actionIntent.send(bundle);
} else {
actionIntent.send();
}
}
mActionLookup.remove(handle);
} catch (final PendingIntent.CanceledException e) {
@ -500,7 +508,7 @@ public class NotificationListener extends NotificationListenerService {
notificationSpec.attachedActions.add(customAction);
customAction.handle = ((long) notificationSpec.getId() << 4) + notificationSpec.attachedActions.size();
mActionLookup.add((int) customAction.handle, new NotificationAction(act.actionIntent, remoteInput));
LOG.info("Found custom action {}: {} - {} {}", notificationSpec.attachedActions.size(), (int) customAction.handle, act.title, sbn.getTag());
LOG.info("Found custom action {}: {} - {}", notificationSpec.attachedActions.size(), (int) customAction.handle, act.title);
}
}

View File

@ -51,7 +51,7 @@ public class ActivityUser {
private static final String defaultUserName = "gadgetbridge-user";
public static final int defaultUserGender = GENDER_FEMALE;
public static final String defaultUserDateOfBirth = "1970-01-01";
public static final String defaultUserDateOfBirth = "2000-01-01";
public static final int defaultUserAge = 0;
public static final int defaultUserHeightCm = 175;
public static final int defaultUserWeightKg = 70;

View File

@ -83,6 +83,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.Ga
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255SMusicCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner265Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner265SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner45Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner55Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner620Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner955Coordinator;
@ -457,6 +458,7 @@ public enum DeviceType {
GARMIN_FENIX_7X(GarminFenix7XCoordinator.class),
GARMIN_FENIX_7_PRO(GarminFenix7ProCoordinator.class),
GARMIN_FENIX_8(GarminFenix8Coordinator.class),
GARMIN_FORERUNNER_45(GarminForerunner45Coordinator.class),
GARMIN_FORERUNNER_55(GarminForerunner55Coordinator.class),
GARMIN_FORERUNNER_165(GarminForerunner165Coordinator.class),
GARMIN_FORERUNNER_235(GarminForerunner235Coordinator.class),

View File

@ -16,6 +16,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.SendNotificationRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.SendNotificationRemoveRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.SendSMSReplyAck;
public class HuaweiNotificationsManager {
private static final Logger LOG = LoggerFactory.getLogger(HuaweiNotificationsManager.class);
@ -105,16 +106,25 @@ public class HuaweiNotificationsManager {
LOG.info("Reply is empty");
return;
}
if(response.type != 1 && response.type != 2) {
LOG.info("Reply: only type 1 and 2 supported");
return;
}
NotificationSpec notificationSpec = null;
for (NotificationSpec spec : notificationSpecCache) {
if (getNotificationKey(spec).equals(response.key)) {
notificationSpec = spec;
break;
if(response.type == 1) { // generic SMS notification reply. Find by phone number
for (NotificationSpec spec : notificationSpecCache) {
if (spec.phoneNumber.equals(response.key)) {
notificationSpec = spec;
break;
}
}
} else if(response.type == 2) {
for (NotificationSpec spec : notificationSpecCache) {
if (getNotificationKey(spec).equals(response.key)) {
notificationSpec = spec;
break;
}
}
} else {
LOG.info("Reply type {} is not supported", response.type);
return;
}
if (notificationSpec == null) {
LOG.info("Notification for reply is not found");
@ -139,7 +149,21 @@ public class HuaweiNotificationsManager {
}
}
this.support.evaluateGBDeviceEvent(deviceEvtNotificationControl);
//TODO: maybe should be send reply. Service: 0x2, command: 0x10, tlv 7 and/or 1, type byte, 7f on error
if(response.type == 1) {
// NOTE: send response only for SMS reply
try {
// 0xff - OK
// 0x7f - error
// TODO: get response from SMSManager. Send pending intent result.
// result can be one of the RESULT_ERROR_* from SmsManager. Not sure, need to check.
// currently always send OK.
byte resultCode = (byte)0xff;
SendSMSReplyAck sendNotificationReq = new SendSMSReplyAck(this.support, resultCode);
sendNotificationReq.doPerform();
} catch (IOException e) {
LOG.error("Sending sns reply ACK", e);
}
}
}
}

View File

@ -0,0 +1,29 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Notifications;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiSupportProvider;
public class SendSMSReplyAck extends Request {
private final byte resultCode;
public SendSMSReplyAck(HuaweiSupportProvider support,
byte resultCode) {
super(support);
this.serviceId = Notifications.id;
this.commandId = Notifications.NotificationReply.id;
this.resultCode = resultCode;
this.addToResponse = false;
}
@Override
protected List<byte[]> createRequest() throws RequestCreationException {
try {
return new Notifications.NotificationReply.ReplyAck(this.paramsProvider, this.resultCode).serialize();
} catch(HuaweiPacket.CryptoException e) {
throw new RequestCreationException(e.getMessage());
}
}
}

View File

@ -17,6 +17,7 @@
package nodomain.freeyourgadget.gadgetbridge.util;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ActivityNotFoundException;
@ -28,6 +29,7 @@ import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.provider.Settings;
import android.widget.Toast;
@ -52,11 +54,15 @@ import nodomain.freeyourgadget.gadgetbridge.externalevents.NotificationListener;
public class PermissionsUtils {
private static final Logger LOG = LoggerFactory.getLogger(PermissionsUtils.class);
public static final String CUSTOM_PERM_IGNORE_BATT_OPTIM = "custom_perm_ignore_battery_optimization";
public static final String CUSTOM_PERM_NOTIFICATION_LISTENER = "custom_perm_notifications_listener";
public static final String CUSTOM_PERM_NOTIFICATION_SERVICE = "custom_perm_notifications_service";
public static final String CUSTOM_PERM_DISPLAY_OVER = "custom_perm_display_over";
public static final List<String> specialPermissions = new ArrayList<String>() {{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
add(CUSTOM_PERM_IGNORE_BATT_OPTIM);
}
add(CUSTOM_PERM_NOTIFICATION_LISTENER);
add(CUSTOM_PERM_NOTIFICATION_SERVICE);
add(CUSTOM_PERM_DISPLAY_OVER);
@ -68,6 +74,12 @@ public class PermissionsUtils {
public static ArrayList<PermissionDetails> getRequiredPermissionsList(Activity activity) {
ArrayList<PermissionDetails> permissionsList = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
permissionsList.add(new PermissionDetails(
CUSTOM_PERM_IGNORE_BATT_OPTIM,
activity.getString(R.string.permission_disable_doze_title),
activity.getString(R.string.permission_disable_doze_summary)));
}
permissionsList.add(new PermissionDetails(
CUSTOM_PERM_NOTIFICATION_LISTENER,
activity.getString(R.string.menuitem_notifications),
@ -189,6 +201,9 @@ public class PermissionsUtils {
return ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).isNotificationPolicyAccessGranted();
} else if (permission.equals(CUSTOM_PERM_DISPLAY_OVER) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return Settings.canDrawOverlays(context);
} else if (permission.equals(CUSTOM_PERM_IGNORE_BATT_OPTIM) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
return pm.isIgnoringBatteryOptimizations(context.getApplicationContext().getPackageName());
} else {
return ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_DENIED;
}
@ -205,7 +220,9 @@ public class PermissionsUtils {
}
public static void requestPermission(Activity activity, String permission) {
if (permission.equals(CUSTOM_PERM_NOTIFICATION_LISTENER)) {
if (permission.equals(CUSTOM_PERM_IGNORE_BATT_OPTIM)) {
showRequestIgnoreBatteryOptimizationDialog(activity);
} else if (permission.equals(CUSTOM_PERM_NOTIFICATION_LISTENER)) {
showNotifyListenerPermissionsDialog(activity);
} else if (permission.equals(CUSTOM_PERM_NOTIFICATION_SERVICE) && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)) {
showNotifyPolicyPermissionsDialog(activity);
@ -242,6 +259,14 @@ public class PermissionsUtils {
}
}
@SuppressLint("BatteryLife")
private static void showRequestIgnoreBatteryOptimizationDialog(Activity activity) {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + activity.getApplicationContext().getPackageName()));
activity.startActivity(intent);
}
private static void showNotifyListenerPermissionsDialog(Activity activity) {
new MaterialAlertDialogBuilder(activity)
.setMessage(activity.getString(R.string.permission_notification_listener,

View File

@ -27,9 +27,9 @@ import java.util.Date;
import java.util.Locale;
public class XDatePreference extends DialogPreference {
private int year;
private int month;
private int day;
private int year = 1970;
private int month = 1;
private int day = 1;
private long minDate; // TODO actually read minDate
private long maxDate; // TODO actually read maxDate

View File

@ -1774,6 +1774,7 @@
<string name="devicetype_garmin_instinct_2_solar">Garmin Instinct 2 Solar</string>
<string name="devicetype_garmin_instinct_2_soltac">Garmin Instinct 2 SolTac</string>
<string name="devicetype_garmin_instinct_crossover">Garmin Instinct Crossover</string>
<string name="devicetype_garmin_forerunner_45">Garmin Forerunner 45</string>
<string name="devicetype_garmin_forerunner_55">Garmin Forerunner 55</string>
<string name="devicetype_garmin_forerunner_165">Garmin Forerunner 165</string>
<string name="devicetype_garmin_forerunner_235">Garmin Forerunner 235</string>
@ -3584,4 +3585,6 @@
<string name="battery_allow_pass_though_summary">When enabled, the battery can be charged while discharging</string>
<string name="battery_allow_pass_through">Allow battery pass-through</string>
<string name="canned_replies_not_empty">There should be at least one canned reply.</string>
<string name="permission_disable_doze_title">Ignore battery optimizations</string>
<string name="permission_disable_doze_summary">Allows running in the background unhindered by Android\'s battery optimizations</string>
</resources>

View File

@ -15,6 +15,7 @@
<nodomain.freeyourgadget.gadgetbridge.util.XDatePreference
app:iconSpaceReserved="false"
android:defaultValue="2000-01-01"
android:key="activity_user_date_of_birth"
android:title="@string/activity_prefs_date_birth" />