From b8a85e03ea6147bf88b398214f06484e94af6bde Mon Sep 17 00:00:00 2001 From: Martin Braun Date: Thu, 21 May 2026 19:57:07 +0200 Subject: [PATCH] Add Third Party Alarm Intent API: Set and Dismiss alarms for your device from any app (#6088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the capability to set and dismiss alarms from third party apps through an Intent API, fixes #5553 **This PR was co-authored by ChatGPT and José Rebelo (passively):** - Scaffold for the core component `DeviceAlarmReceiver` was taken from José's `DeviceSettingsReceiver` - The core implementation was written by me by hand, ChatGPT was used to review and find potential issues and bugs - The `DeviceAlarmReceiverTest` unit tests were written by ChatGPT and reviewed by me **About these changes:** - Adds a new developer settings entry for the device that the user needs to enable, so other apps can use the Intent API for such device - Supports app blacklisting as it is done in the `DeviceSettingsReceiver` - `ConfigureAlarms.addMissingAlarms()` was migrated to `DBHelper.fillMissingAlarms()` to provide this abstraction globally - `AlarmReceiver` was migrated to `SunriseSunsetAlarmReceiver` to avoid ambiguity with this feature; the formerly called `AlarmReceiver` was related to setting calendar events based on the sunrise/sunset via an Intent API - The API is registered in the `DeviceCommunicationService` - The `ConfigureAlarms` activity updates its list after setting alarms from a third party app by broadcasting `ACTION_SAVE_ALARMS` - It is possible to set alarms of a device that is offline, it should sync when the device connects next time, unless it downloads the alarms from the device when connecting - However, this device-to-GadgetBridge alarm sync API is not commonly abstracted, so we cannot respect existing alarms on the devices when changing alarms within the new Intent API; the database is the single source of truth and connecting the device after using the API will reset alarms - ~~The API was designed to align with the `AlarmClock` API from Android, but~~ there is no feature parity to improve the design and interaction; after all the `AlarmClock` API is interacted with `startActivity`, but `GadgetBridge` uses `sendBroadcast` receivers, which is recommend for background tasks - Errors are not raised to the caller (i.e. providing unknown or invalid Mac Address of device or trying to set an alarm on a nonexisting slot); these errors are logged though **EDIT: Additional changes during the development:** - Since slot IDs are unpredictable, the flow is title-driven (there is no way to provide an ID) - Dismissing an alarm through the API removes its title - Only alarms without a title can be overwritten and a title should be set through this API to retain the ability to dismiss it without dismissing all alarms of the user - Dismissing all alarms regardless of the title is still possible - The title is exposed in the UI not just when the device supports it, but also when this API is enabled in the developer settings of the device itself **Example usage:** - https://gitlab.com/martin-braun/warpclock-plus/-/blob/feat/gb/app/src/main/java/com/antonok/warpclock/AlarmIntentService.kt demonstrates dismissing all alarms and setting multiple alarms (search for `sendBroadcast`) - `DeviceAlarmReceiverTest` should cover all cases for a more complete overview of the capabilities of the API Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6088 --- .../gadgetbridge/activities/AlarmDetails.java | 11 +- .../activities/ConfigureAlarms.java | 29 +- .../DeviceSpecificSettings.java | 6 + .../DeviceSpecificSettingsFragment.java | 14 +- .../adapter/ItemWithDetailsAdapter.java | 35 +- .../gadgetbridge/database/DBHelper.java | 47 ++ .../externalevents/DeviceAlarmReceiver.java | 234 ++++++++ ...r.java => SunriseSunsetAlarmReceiver.java} | 6 +- .../service/DeviceCommunicationService.java | 15 +- .../service/devices/pebble/PebbleSupport.java | 6 +- app/src/main/res/layout/item_with_details.xml | 4 +- .../layout/item_with_details_horizontal.xml | 4 +- .../res/layout/item_with_details_small.xml | 4 +- app/src/main/res/values/strings.xml | 2 + ...devicesettings_alarms_third_party_apps.xml | 10 + .../DeviceAlarmReceiverTest.java | 564 ++++++++++++++++++ 16 files changed, 940 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiver.java rename app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/{AlarmReceiver.java => SunriseSunsetAlarmReceiver.java} (95%) create mode 100644 app/src/main/res/xml/devicesettings_alarms_third_party_apps.xml create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiverTest.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java index f1d395d386..405c620ff7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java @@ -1,5 +1,6 @@ /* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel - Dakhno, Daniele Gobbetti, Dmitry Markin, Lem Dulfo, Taavi Eomäe, Martin.JM + Dakhno, Daniele Gobbetti, Dmitry Markin, Lem Dulfo, Martin.JM, Martin Braun, + Taavi Eomäe This file is part of Gadgetbridge. @@ -167,7 +168,7 @@ public class AlarmDetails extends AbstractGBActivity { binding.soundModeSpinner.setAdapter(adapter); } - binding.title.setVisibility(supportsTitle() ? View.VISIBLE : View.GONE); + binding.title.setVisibility(shouldShowTitle() ? View.VISIBLE : View.GONE); binding.title.setText(alarm.getTitle()); final int titleLimit = getAlarmTitleLimit(); @@ -202,8 +203,10 @@ public class AlarmDetails extends AbstractGBActivity { return device.getDeviceCoordinator().forcedSmartWakeup(device, position); } - private boolean supportsTitle() { - return device.getDeviceCoordinator().supportsAlarmTitle(device); + private boolean shouldShowTitle() { + return device.getDeviceCoordinator().supportsAlarmTitle(device) || + GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()) + .getBoolean("third_party_apps_set_alarms", false); } private int getAlarmTitleLimit() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java index 051bb86b45..f5212f123a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java @@ -1,6 +1,6 @@ /* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien Gaignon, Daniel Dakhno, Daniele Gobbetti, Dmitry Markin, José Rebelo, - Lem Dulfo, Petr Vaněk + Lem Dulfo, Martin Braun, Petr Vaněk This file is part of Gadgetbridge. @@ -103,7 +103,7 @@ public class ConfigureAlarms extends AbstractGBActivity { alarms = AlarmUtils.readAlarmsFromPrefs(getGbDevice()); storeMigratedAlarms(alarms); } - addMissingAlarms(alarms); + DBHelper.fillMissingAlarms(gbDevice, alarms); mGBAlarmListAdapter.setAlarmList(alarms); mGBAlarmListAdapter.notifyDataSetChanged(); @@ -115,31 +115,6 @@ public class ConfigureAlarms extends AbstractGBActivity { } } - private void addMissingAlarms(List alarms) { - DeviceCoordinator coordinator = getGbDevice().getDeviceCoordinator(); - int supportedNumAlarms = coordinator.getAlarmSlotCount(getGbDevice()); - if (supportedNumAlarms > alarms.size()) { - try (DBHandler db = GBApplication.acquireDB()) { - DaoSession daoSession = db.getDaoSession(); - for (int position = 0; position < supportedNumAlarms; position++) { - boolean found = false; - for (Alarm alarm : alarms) { - if (alarm.getPosition() == position) { - found = true; - break; - } - } - if (!found) { - LOG.info("adding missing alarm at position {}", position); - alarms.add(position, AlarmUtils.createDefaultAlarm(daoSession, getGbDevice(), position)); - } - } - } catch (Exception e) { - LOG.error("Error accessing database", e); - } - } - } - @Override public boolean onOptionsItemSelected(final MenuItem item) { final int itemId = item.getItemId(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java index e4c0a18e51..3b80bd7f12 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java @@ -82,6 +82,12 @@ public class DeviceSpecificSettings implements Parcelable { return subScreenScreens; } + public List addRootScreen(final DeviceSpecificSettingsScreen screen, final List subScreens) { + final List subScreenScreens = addRootScreen(screen); + subScreenScreens.addAll(subScreens); + return subScreenScreens; + } + public void addRootScreen(final int index, @XmlRes final int screen) { rootScreens.add(index, screen); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index bf4d8d838c..b56b415c57 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -65,6 +65,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -1664,11 +1665,20 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i R.xml.devicesettings_device_support_can_reconnect ); - deviceSpecificSettings.addRootScreen( - DeviceSpecificSettingsScreen.DEVELOPER, + final List intentApiSubScreens = new ArrayList<>(); + Collections.addAll( + intentApiSubScreens, R.xml.devicesettings_header_intent_api, R.xml.devicesettings_settings_third_party_apps ); + if (coordinator.getAlarmSlotCount(device) > 0) { + intentApiSubScreens.add(R.xml.devicesettings_alarms_third_party_apps); + } + deviceSpecificSettings.addRootScreen( + DeviceSpecificSettingsScreen.DEVELOPER, + intentApiSubScreens + ); + if (coordinator.getConnectionType().usesBluetoothLE()) { deviceSpecificSettings.addRootScreen( DeviceSpecificSettingsScreen.DEVELOPER, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java index 3bb2e079fe..489c41f03d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java @@ -1,5 +1,5 @@ /* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten - Pfeiffer + Pfeiffer, Martin Braun This file is part of Gadgetbridge. @@ -17,11 +17,19 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.adapter; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.graphics.Bitmap; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; import java.util.List; +import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails; /** @@ -52,4 +60,29 @@ public class ItemWithDetailsAdapter extends AbstractItemAdapter protected Bitmap getPreview(ItemWithDetails item) { return item.getPreview(); } + + @Override + @NonNull + public View getView(final int position, final View view, final ViewGroup parent) { + final View itemView = super.getView(position, view, parent); + final ItemWithDetails item = getItem(position); + + if (item != null && item.getDetails() != null && !item.getDetails().isEmpty()) { + itemView.setOnClickListener(v -> { + final ClipboardManager clipboard = (ClipboardManager) + getContext().getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.setPrimaryClip( + ClipData.newPlainText(item.getName(), item.getDetails()) + ); + Toast.makeText(getContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT) + .show(); + } + }); + } else { + itemView.setOnClickListener(null); + } + + return itemView; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java index 67278fdd4f..5bf4947232 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java @@ -68,6 +68,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.model.ValidByDate; +import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; @@ -558,6 +559,52 @@ public class DBHelper { return Collections.emptyList(); } + /** + * Returns all user-configurable alarms for the given user and device filled with default alarms + * for unoccupied slots. The list is sorted by {@link Alarm#getPosition()}. Calendar events that + * may also be modeled as alarms are not stored in the database and hence not returned by this + * method. + * @param gbDevice the device for which the alarms shall be loaded + * @return the list of alarms for the given device + */ + @NonNull + public static List getAlarmsWithDefaults(@NonNull GBDevice gbDevice) { + List alarms = getAlarms(gbDevice); + fillMissingAlarms(gbDevice, alarms); + return alarms; + } + + /** + * Fills default alarms in the alarm list for unoccupied slots of the device. + * @param gbDevice the device for which the alarms shall be loaded + * @param alarms list of alarms to fill + */ + public static void fillMissingAlarms(@NonNull GBDevice gbDevice, List alarms) { + DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator(); + int supportedNumAlarms = coordinator.getAlarmSlotCount(gbDevice); + if (supportedNumAlarms > alarms.size()) { + try (DBHandler db = GBApplication.acquireDB()) { + DaoSession daoSession = db.getDaoSession(); + for (int position = 0; position < supportedNumAlarms; position++) { + boolean found = false; + for (Alarm alarm : alarms) { + if (alarm.getPosition() == position) { + found = true; + break; + } + } + if (!found) { + LOG.info("adding missing alarm at position {}", position); + alarms.add(position, AlarmUtils.createDefaultAlarm(daoSession, gbDevice, position)); + } + } + } catch (Exception e) { + LOG.error("Error accessing database", e); + } + } + } + + public static void store(Alarm alarm) { try (DBHandler db = GBApplication.acquireDB()) { DaoSession daoSession = db.getDaoSession(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiver.java new file mode 100644 index 0000000000..b4ddb91356 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiver.java @@ -0,0 +1,234 @@ +/* Copyright (C) 2022-2026 José Rebelo, Martin Braun + + 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. + + + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.externalevents; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Objects; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.entities.Alarm; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; + +public class DeviceAlarmReceiver extends BroadcastReceiver { + private static final Logger LOG = LoggerFactory.getLogger(DeviceAlarmReceiver.class); + + private static final String macAddrPattern = "^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$"; + + public static final String COMMAND_SET_ALARM = "nodomain.freeyourgadget.gadgetbridge.command.SET_ALARM"; + public static final String COMMAND_DISMISS_ALARM = "nodomain.freeyourgadget.gadgetbridge.command.DISMISS_ALARM"; + + public static final String EXTRA_MAC_ADDR = "device"; + public static final String EXTRA_DAYS = "days"; + public static final String EXTRA_HOUR = "hour"; + public static final String EXTRA_MINUTES = "minutes"; + public static final String EXTRA_TITLE = "title"; + public static final String EXTRA_ALARM_SEARCH_MODE = "mode"; + public static final String ALARM_SEARCH_MODE_ALL = "all"; + public static final String ALARM_SEARCH_MODE_TIME = "time"; + public static final String ALARM_SEARCH_MODE_TITLE = "title"; + + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + + if (!COMMAND_SET_ALARM.equals(action) && !COMMAND_DISMISS_ALARM.equals(action)) { + LOG.warn("Unexpected action {}", intent.getAction()); + } + LOG.debug("Got third party alarm action: {}", action); + + final String deviceAddress = intent.getStringExtra(EXTRA_MAC_ADDR); + + if (deviceAddress == null) { + LOG.warn("Missing device address"); + return; + } + + if (!deviceAddress.matches(macAddrPattern)) { + LOG.warn("Device address '{}' does not match '{}'", deviceAddress, macAddrPattern); + return; + } + + final GBDevice targetDevice = GBApplication.app() + .getDeviceManager() + .getDeviceByAddress(deviceAddress); + + if (targetDevice == null) { + LOG.warn("Unknown device {}", deviceAddress); + return; + } + + final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(targetDevice.getAddress()); + + if (!prefs.getBoolean("third_party_apps_set_alarms", false)) { + LOG.warn("Setting alarms from 3rd party apps not allowed for {}", deviceAddress); + return; + } + + // TODO: sync alarms back from device to DB to avoid losing device-exclusive changes + // (this requires a common interface for back-syncing alarms first) + final ArrayList alarms = (ArrayList) DBHelper.getAlarmsWithDefaults(targetDevice); + if (alarms.isEmpty()) { + LOG.error("Alarms are not supported on this device"); + return; + } + + boolean changed = false; + final String mode = intent.getStringExtra(EXTRA_ALARM_SEARCH_MODE); + final String title = intent.getStringExtra(EXTRA_TITLE); + if (COMMAND_SET_ALARM.equals(action)) { + final int hour = intent.getIntExtra(EXTRA_HOUR, -1); + final int minute = intent.getIntExtra(EXTRA_MINUTES, -1); + if ((hour == -1 || minute == -1)) { + LOG.error("Both hour and minutes have to be provided when creating an alarm"); + return; + } + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + LOG.error("Invalid time provided"); + return; + } + final ArrayList days = intent.getIntegerArrayListExtra(EXTRA_DAYS); // Calendar.SUNDAY, Calendar.MONDAY, Cal..; + int repetition = convertRepetitionMask(days); + // find the next free slot: a dismissed alarm without any title + // (this way the user can protect dismissed alarms by naming them) + for (Alarm alarm : alarms) { + if (!alarm.getEnabled() && ( + alarm.getTitle() == null || alarm.getTitle().isEmpty())) { + updateAlarm( + alarm, true, hour, minute, repetition, + title + ); + DBHelper.store(alarm); + changed = true; + break; + } + } + if (!changed) { + LOG.error("No free alarm slot was found; A slot is free when it's disabled and has no title"); + return; + } + } else if (COMMAND_DISMISS_ALARM.equals(action)) { + if (!Objects.equals(mode, ALARM_SEARCH_MODE_TIME) && + !Objects.equals(mode, ALARM_SEARCH_MODE_ALL) && + !Objects.equals(mode, ALARM_SEARCH_MODE_TITLE)) { + LOG.error("Unknown mode"); + return; + } + final int hour = intent.getIntExtra(EXTRA_HOUR, -1); + final int minutes = intent.getIntExtra(EXTRA_MINUTES, -1); + if (Objects.equals(mode, ALARM_SEARCH_MODE_TIME) && hour == -1) { + LOG.error("Hour has to be provided when dismissing an alarm by time"); + return; + } + if (Objects.equals(mode, ALARM_SEARCH_MODE_TITLE) + && (title == null || title.isEmpty())) { + LOG.error("Title has to be provided when dismissing an alarm by title"); + return; + } + for (Alarm alarm : alarms) { + if (Objects.equals(mode, ALARM_SEARCH_MODE_ALL) || + (Objects.equals(mode, ALARM_SEARCH_MODE_TITLE) + && alarm.getTitle() != null + && alarm.getTitle().contains(title) + ) || + (Objects.equals(mode, ALARM_SEARCH_MODE_TIME) + && alarm.getHour() == hour + && (minutes == -1 || alarm.getMinute() == minutes) + ) + ) { + // dismiss the alarm and clear its title, so it can be set again + updateAlarm(alarm, false, alarm.getHour(), alarm.getMinute(), 0, ""); + DBHelper.store(alarm); + changed = true; + } + } + if (!changed) { + LOG.warn("No alarm to dismiss was found"); + } + } else { + LOG.error("Unknown action"); + return; + } + + if (changed) { + LocalBroadcastManager.getInstance(context).sendBroadcast( + new Intent(DeviceService.ACTION_SAVE_ALARMS) + ); + GBApplication.deviceService(targetDevice).onSetAlarms(alarms); + } + } + + private static int convertRepetitionMask(ArrayList days) { + int repetitionMask = 0; + if (days != null && !days.isEmpty()) { + for (int day : days) { + switch (day) { + case Calendar.MONDAY: + repetitionMask |= Alarm.ALARM_MON; + break; + case Calendar.TUESDAY: + repetitionMask |= Alarm.ALARM_TUE; + break; + case Calendar.WEDNESDAY: + repetitionMask |= Alarm.ALARM_WED; + break; + case Calendar.THURSDAY: + repetitionMask |= Alarm.ALARM_THU; + break; + case Calendar.FRIDAY: + repetitionMask |= Alarm.ALARM_FRI; + break; + case Calendar.SATURDAY: + repetitionMask |= Alarm.ALARM_SAT; + break; + case Calendar.SUNDAY: + repetitionMask |= Alarm.ALARM_SUN; + break; + } + } + } + return repetitionMask; + } + + private static void updateAlarm(Alarm alarm, boolean enable, int hour, int minute, int repetition, String title) { + alarm.setHour(hour); + alarm.setMinute(minute); + alarm.setRepetition(repetition); + alarm.setTitle(title); + alarm.setEnabled(enable); + } + + public IntentFilter buildFilter() { + final IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(COMMAND_SET_ALARM); + intentFilter.addAction(COMMAND_DISMISS_ALARM); + return intentFilter; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SunriseSunsetAlarmReceiver.java similarity index 95% rename from app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java rename to app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SunriseSunsetAlarmReceiver.java index d63aec6276..158a49269b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SunriseSunsetAlarmReceiver.java @@ -39,10 +39,10 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; -public class AlarmReceiver extends BroadcastReceiver { - private static final Logger LOG = LoggerFactory.getLogger(AlarmReceiver.class); +public class SunriseSunsetAlarmReceiver extends BroadcastReceiver { + private static final Logger LOG = LoggerFactory.getLogger(SunriseSunsetAlarmReceiver.class); - public AlarmReceiver() { + public SunriseSunsetAlarmReceiver() { Context context = GBApplication.getContext(); Intent intent = new Intent("DAILY_ALARM"); intent.setPackage(BuildConfig.APPLICATION_ID); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java index f344ed1dea..b1a27f4042 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java @@ -1,11 +1,11 @@ -/* Copyright (C) 2015-2025 Andreas Böhler, Andreas Shimokawa, Arjan +/* Copyright (C) 2015-2026 Andreas Böhler, Andreas Shimokawa, Arjan Schrijver, Avamander, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Daniel Hauck, Davis Mosenkovs, Dikay900, Dmitriy Bogdanov, Frank Slezak, Gabriele Monaco, Gordon Williams, ivanovlev, João Paulo Barraca, José - Rebelo, Julien Pivotto, Kasha, keeshii, Martin, Matthieu Baerts, mvn23, - NekoBox, Nephiel, Petr Vaněk, Sebastian Kranz, Sergey Trofimov, Steffen - Liebergeld, Taavi Eomäe, TylerWilliamson, Uwe Hermann, Yoran Vulker, - Thomas Kuehne + Rebelo, Julien Pivotto, Kasha, keeshii, Martin, Martin Braun, Matthieu + Baerts, mvn23, NekoBox, Nephiel, Petr Vaněk, Sebastian Kranz, Sergey + Trofimov, Steffen Liebergeld, Taavi Eomäe, TylerWilliamson, Uwe Hermann, + Yoran Vulker, Thomas Kuehne This file is part of Gadgetbridge. @@ -79,6 +79,7 @@ import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCar import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.externalevents.AlarmClockReceiver; +import nodomain.freeyourgadget.gadgetbridge.externalevents.DeviceAlarmReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.BluetoothConnectReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.BluetoothPairingRequestReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.CMWeatherReceiver; @@ -553,6 +554,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere ContextCompat.registerReceiver(this, deviceSettingsReceiver, deviceSettingsIntentFilter, ContextCompat.RECEIVER_EXPORTED); globalReceivers.add(deviceSettingsReceiver); + final DeviceAlarmReceiver deviceAlarmReceiver = new DeviceAlarmReceiver(); + ContextCompat.registerReceiver(this, deviceAlarmReceiver, deviceAlarmReceiver.buildFilter(), ContextCompat.RECEIVER_EXPORTED); + globalReceivers.add(deviceAlarmReceiver); + final IntentApiReceiver intentApiReceiver = new IntentApiReceiver(); ContextCompat.registerReceiver(this, intentApiReceiver, intentApiReceiver.buildFilter(), ContextCompat.RECEIVER_EXPORTED); globalReceivers.add(intentApiReceiver); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleSupport.java index 4fc2d79da7..361fce5a42 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleSupport.java @@ -38,7 +38,7 @@ import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; -import nodomain.freeyourgadget.gadgetbridge.externalevents.AlarmReceiver; +import nodomain.freeyourgadget.gadgetbridge.externalevents.SunriseSunsetAlarmReceiver; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; @@ -52,7 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; public class PebbleSupport extends AbstractSerialDeviceSupport { private static final Logger LOG = LoggerFactory.getLogger(PebbleSupport.class); - private AlarmReceiver mAlarmReceiver = null; + private SunriseSunsetAlarmReceiver mAlarmReceiver = null; @Override public void dispose() { @@ -69,7 +69,7 @@ public class PebbleSupport extends AbstractSerialDeviceSupport { } unregisterSunriseSunsetAlarmReceiver(); LOG.info("registering sunrise and sunset receiver"); - this.mAlarmReceiver = new AlarmReceiver(); + this.mAlarmReceiver = new SunriseSunsetAlarmReceiver(); ContextCompat.registerReceiver(GBApplication.getContext(), mAlarmReceiver, new IntentFilter("DAILY_ALARM"), ContextCompat.RECEIVER_EXPORTED); } diff --git a/app/src/main/res/layout/item_with_details.xml b/app/src/main/res/layout/item_with_details.xml index 6aba6efe7e..b0a420a41f 100644 --- a/app/src/main/res/layout/item_with_details.xml +++ b/app/src/main/res/layout/item_with_details.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?android:attr/activatedBackgroundIndicator" + android:background="?android:attr/selectableItemBackground" android:paddingTop="4dp" android:paddingBottom="4dp"> @@ -67,4 +67,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_with_details_horizontal.xml b/app/src/main/res/layout/item_with_details_horizontal.xml index ac2d5373e7..0f9924f043 100644 --- a/app/src/main/res/layout/item_with_details_horizontal.xml +++ b/app/src/main/res/layout/item_with_details_horizontal.xml @@ -2,7 +2,7 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_with_details_small.xml b/app/src/main/res/layout/item_with_details_small.xml index 02146abc20..55243b8bb2 100644 --- a/app/src/main/res/layout/item_with_details_small.xml +++ b/app/src/main/res/layout/item_with_details_small.xml @@ -2,7 +2,7 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62f63415e9..ac25b0e975 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1141,6 +1141,8 @@ This might help on devices where firmware flashing fails. Allow 3rd party apps to change settings Allow other installed 3rd party apps to set device settings through intents. + Allow 3rd party apps to set alarms + Allow other installed 3rd party apps to set device alarms through intents. Steps history Current steps/min Total steps diff --git a/app/src/main/res/xml/devicesettings_alarms_third_party_apps.xml b/app/src/main/res/xml/devicesettings_alarms_third_party_apps.xml new file mode 100644 index 0000000000..67cdd7d320 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_alarms_third_party_apps.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiverTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiverTest.java new file mode 100644 index 0000000000..fcbe971642 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceAlarmReceiverTest.java @@ -0,0 +1,564 @@ +package nodomain.freeyourgadget.gadgetbridge.externalevents; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Application; +import android.content.Intent; +import android.content.SharedPreferences; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.List; +import java.lang.reflect.Field; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.Alarm; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService; +import nodomain.freeyourgadget.gadgetbridge.test.TestBase; +import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; + +public class DeviceAlarmReceiverTest extends TestBase { + @Rule + public final TestName testName = new TestName(); + + private final DeviceAlarmReceiver receiver = new DeviceAlarmReceiver(); + + private GBDevice device; + + @Override + public void setUp() throws Exception { + super.setUp(); + + device = createThreeSlotDevice(); + device.setState(GBDevice.State.INITIALIZED); + DBHelper.getDevice(device, daoSession); + registerDeviceWithManager(device); + + final SharedPreferences devicePrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()); + assertNotNull(devicePrefs); + devicePrefs.edit() + .clear() + .putBoolean("third_party_apps_set_alarms", true) + .commit(); + + drainStartedServices(); + } + + @Test + public void setAlarm_rejectsMissingDeviceAddress() { + receiver.onReceive(getContext(), new Intent(DeviceAlarmReceiver.COMMAND_SET_ALARM)); + + assertNull(getNextStartedService()); + assertEquals(0, DBHelper.getAlarms(device).size()); + } + + @Test + public void setAlarm_rejectsInvalidMacAddress() { + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_SET_ALARM); + intent.putExtra(DeviceAlarmReceiver.EXTRA_MAC_ADDR, "invalid"); + + receiver.onReceive(getContext(), intent); + + assertNull(getNextStartedService()); + assertEquals(0, DBHelper.getAlarms(device).size()); + } + + @Test + public void setAlarm_rejectsUnknownDevice() { + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_SET_ALARM); + intent.putExtra(DeviceAlarmReceiver.EXTRA_MAC_ADDR, "00:00:00:00:00:00"); + + receiver.onReceive(getContext(), intent); + + assertNull(getNextStartedService()); + assertEquals(0, DBHelper.getAlarms(device).size()); + } + + @Test + public void setAlarm_rejectsWhenThirdPartyAlarmsAreDisabled() { + GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).edit() + .putBoolean("third_party_apps_set_alarms", false) + .commit(); + + final Intent intent = setAlarmIntent(7, 30, "Morning run"); + + receiver.onReceive(getContext(), intent); + + assertNull(getNextStartedService()); + assertEquals(0, DBHelper.getAlarms(device).size()); + } + + @Test + public void setAlarm_usesFirstUntitledDisabledSlotWithRepetition() { + storeAlarm(0, true, 6, 15, 0, "personal"); + storeAlarm(1, false, 6, 30, 0, ""); + storeAlarm(2, false, 8, 45, 0, ""); + + final Intent intent = setAlarmIntent(7, 30, "Morning run"); + intent.putIntegerArrayListExtra( + DeviceAlarmReceiver.EXTRA_DAYS, + new ArrayList<>(Arrays.asList(Calendar.MONDAY, Calendar.WEDNESDAY, Calendar.SUNDAY)) + ); + + receiver.onReceive(getContext(), intent); + + final Alarm alarm = getAlarm(1); + assertTrue(alarm.getEnabled()); + assertEquals(7, alarm.getHour()); + assertEquals(30, alarm.getMinute()); + assertEquals("Morning run", alarm.getTitle()); + assertEquals( + Alarm.ALARM_MON + Alarm.ALARM_WED + Alarm.ALARM_SUN, + alarm.getRepetition() + ); + + final Intent forwardedIntent = getNextStartedService(); + assertForwardedAlarmUpdate(forwardedIntent, 3); + assertNull(getNextStartedService()); + } + + @Test + public void setAlarm_skipsDismissedAlarmWithTitleAndUsesUntitledSlot() { + storeAlarm(0, false, 6, 30, 0, "protected personal alarm"); + storeAlarm(1, false, 6, 30, 0, ""); + storeAlarm(2, false, 6, 30, 0, "second free"); + + final Intent intent = setAlarmIntent(9, 45, "Auto slot"); + + receiver.onReceive(getContext(), intent); + + final Alarm protectedAlarm = getAlarm(0); + assertFalse(protectedAlarm.getEnabled()); + assertEquals("protected personal alarm", protectedAlarm.getTitle()); + + final Alarm reused = getAlarm(1); + assertTrue(reused.getEnabled()); + assertEquals(9, reused.getHour()); + assertEquals(45, reused.getMinute()); + assertEquals("Auto slot", reused.getTitle()); + + final Alarm untouched2 = getAlarm(2); + assertFalse(untouched2.getEnabled()); + assertEquals("second free", untouched2.getTitle()); + + assertForwardedAlarmUpdate(getNextStartedService(), 3); + } + + @Test + public void setAlarm_rejectsWhenNoFreeSlotIsAvailable() { + storeAlarm(0, true, 6, 30, 0, "slot 1"); + storeAlarm(1, false, 7, 30, 0, "protected dismissed slot"); + storeAlarm(2, true, 8, 30, 0, "slot 3"); + + final Intent intent = setAlarmIntent(9, 45, "No space"); + + receiver.onReceive(getContext(), intent); + + assertNull(getNextStartedService()); + assertEquals(3, DBHelper.getAlarms(device).size()); + assertEquals("slot 1", getAlarm(0).getTitle()); + assertEquals("protected dismissed slot", getAlarm(1).getTitle()); + assertEquals("slot 3", getAlarm(2).getTitle()); + } + + @Test + public void setAlarm_allowsMissingLabel() { + storeAlarm(0, false, 6, 30, 0, ""); + storeAlarm(1, true, 7, 45, 0, "personal"); + storeAlarm(2, false, 8, 15, 0, "reserved"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_SET_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 5) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, 50); + + receiver.onReceive(getContext(), intent); + + final Alarm alarm = getAlarm(0); + assertTrue(alarm.getEnabled()); + assertEquals(5, alarm.getHour()); + assertEquals(50, alarm.getMinute()); + assertNull(alarm.getTitle()); + assertForwardedAlarmUpdate(getNextStartedService(), 3); + } + + @Test + public void setAlarm_rejectsOutOfBoundsHour() { + storeAlarm(0, false, 6, 30, 0, ""); + + final Intent intent = setAlarmIntent(24, 15, "Morning run"); + + receiver.onReceive(getContext(), intent); + + final Alarm alarm = getAlarm(0); + assertFalse(alarm.getEnabled()); + assertEquals(6, alarm.getHour()); + assertEquals(30, alarm.getMinute()); + assertEquals("", alarm.getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void setAlarm_rejectsOutOfBoundsMinute() { + storeAlarm(0, false, 6, 30, 0, ""); + + final Intent intent = setAlarmIntent(7, 60, "Morning run"); + + receiver.onReceive(getContext(), intent); + + final Alarm alarm = getAlarm(0); + assertFalse(alarm.getEnabled()); + assertEquals(6, alarm.getHour()); + assertEquals(30, alarm.getMinute()); + assertEquals("", alarm.getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarm_rejectsUnknownMode() { + storeAlarm(0, true, 6, 30, 0, "slot 0"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, "bogus"); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarm_rejectsLabelModeWithoutMessage() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TITLE); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByAll_disablesEveryStoredAlarm() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + storeAlarm(1, true, 7, 45, 0, "Standup"); + storeAlarm(2, true, 8, 15, 0, "School run"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_ALL); + + receiver.onReceive(getContext(), intent); + + assertFalse(getAlarm(0).getEnabled()); + assertFalse(getAlarm(1).getEnabled()); + assertFalse(getAlarm(2).getEnabled()); + assertEquals("", getAlarm(0).getTitle()); + assertEquals("", getAlarm(1).getTitle()); + assertEquals("", getAlarm(2).getTitle()); + assertForwardedAlarmUpdate(getNextStartedService(), 3); + } + + @Test + public void dismissAlarmByTime_rejectsWhenHourIsMissing() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByTime_matchesProvidedHour() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + storeAlarm(1, true, 6, 45, 0, "Standup"); + storeAlarm(2, true, 8, 15, 0, "School run"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 6); + + receiver.onReceive(getContext(), intent); + + assertFalse(getAlarm(0).getEnabled()); + assertFalse(getAlarm(1).getEnabled()); + assertTrue(getAlarm(2).getEnabled()); + assertEquals("", getAlarm(0).getTitle()); + assertEquals("", getAlarm(1).getTitle()); + assertEquals("School run", getAlarm(2).getTitle()); + assertForwardedAlarmUpdate(getNextStartedService(), 3); + } + + @Test + public void dismissAlarmByTime_rejectsWhenOnlyMinuteIsProvided() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + storeAlarm(1, true, 7, 30, 0, "Standup"); + storeAlarm(2, true, 8, 15, 0, "School run"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, 30); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertTrue(getAlarm(2).getEnabled()); + assertEquals("Wake up", getAlarm(0).getTitle()); + assertEquals("Standup", getAlarm(1).getTitle()); + assertEquals("School run", getAlarm(2).getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByTime_matchesHourAndMinuteWhenBothProvided() { + storeAlarm(0, true, 6, 10, 0, "Hour match"); + storeAlarm(1, true, 9, 30, 0, "Minute match"); + storeAlarm(2, true, 6, 30, 0, "Both match"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 6) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, 30); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertFalse(getAlarm(2).getEnabled()); + assertEquals("Hour match", getAlarm(0).getTitle()); + assertEquals("Minute match", getAlarm(1).getTitle()); + assertEquals("", getAlarm(2).getTitle()); + assertForwardedAlarmUpdate(getNextStartedService(), 3); + } + + @Test + public void dismissAlarmByTime_doesNothingWhenBothProvidedButNoExactMatchExists() { + storeAlarm(0, true, 6, 10, 0, "Hour match"); + storeAlarm(1, true, 9, 30, 0, "Minute match"); + storeAlarm(2, true, 8, 15, 0, "No match"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 6) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, 30); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertTrue(getAlarm(2).getEnabled()); + assertEquals("Hour match", getAlarm(0).getTitle()); + assertEquals("Minute match", getAlarm(1).getTitle()); + assertEquals("No match", getAlarm(2).getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByTime_doesNothingWhenNoAlarmMatches() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + storeAlarm(1, true, 7, 45, 0, "Standup"); + storeAlarm(2, true, 8, 15, 0, "School run"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 11) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, 59); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertTrue(getAlarm(2).getEnabled()); + assertEquals("Wake up", getAlarm(0).getTitle()); + assertEquals("Standup", getAlarm(1).getTitle()); + assertEquals("School run", getAlarm(2).getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByTime_outOfBoundsHourDoesNothing() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + storeAlarm(1, true, 7, 45, 0, "Standup"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 24); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertEquals("Wake up", getAlarm(0).getTitle()); + assertEquals("Standup", getAlarm(1).getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByTime_outOfBoundsMinuteDoesNothing() { + storeAlarm(0, true, 6, 30, 0, "Wake up"); + storeAlarm(1, true, 7, 45, 0, "Standup"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TIME) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, 6) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, 60); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertEquals("Wake up", getAlarm(0).getTitle()); + assertEquals("Standup", getAlarm(1).getTitle()); + assertNull(getNextStartedService()); + } + + @Test + public void dismissAlarmByLabel_matchesContainedTitle() { + storeAlarm(0, true, 6, 30, 0, "Morning workout"); + storeAlarm(1, true, 7, 45, 0, "Workout cooldown"); + storeAlarm(2, true, 8, 15, 0, "School run"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TITLE) + .putExtra(DeviceAlarmReceiver.EXTRA_TITLE, "workout"); + + receiver.onReceive(getContext(), intent); + + assertFalse(getAlarm(0).getEnabled()); + assertTrue(getAlarm(1).getEnabled()); + assertTrue(getAlarm(2).getEnabled()); + assertEquals("", getAlarm(0).getTitle()); + assertEquals("Workout cooldown", getAlarm(1).getTitle()); + assertForwardedAlarmUpdate(getNextStartedService(), 3); + } + + @Test + public void dismissAlarmByLabel_isCaseSensitive() { + storeAlarm(0, true, 6, 30, 0, "Morning workout"); + + final Intent intent = baseIntent(DeviceAlarmReceiver.COMMAND_DISMISS_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_ALARM_SEARCH_MODE, DeviceAlarmReceiver.ALARM_SEARCH_MODE_TITLE) + .putExtra(DeviceAlarmReceiver.EXTRA_TITLE, "Workout"); + + receiver.onReceive(getContext(), intent); + + assertTrue(getAlarm(0).getEnabled()); + assertNull(getNextStartedService()); + } + + private GBDevice createThreeSlotDevice() { + final int suffix = Math.abs(testName.getMethodName().hashCode()) % 256; + final String address = String.format("AA:BB:CC:DD:EE:%02X", suffix); + return new ThreeSlotTestDevice(address); + } + + private void registerDeviceWithManager(final GBDevice device) { + try { + final DeviceManager deviceManager = GBApplication.app().getDeviceManager(); + final Field field = DeviceManager.class.getDeclaredField("deviceList"); + field.setAccessible(true); + final List deviceList = (List) field.get(deviceManager); + deviceList.clear(); + deviceList.add(device); + } catch (final Exception e) { + throw new AssertionError("Failed to register test device", e); + } + } + + private Intent setAlarmIntent(final int hour, final int minutes, final String message) { + return baseIntent(DeviceAlarmReceiver.COMMAND_SET_ALARM) + .putExtra(DeviceAlarmReceiver.EXTRA_HOUR, hour) + .putExtra(DeviceAlarmReceiver.EXTRA_MINUTES, minutes) + .putExtra(DeviceAlarmReceiver.EXTRA_TITLE, message); + } + + private Intent baseIntent(final String action) { + return new Intent(action) + .setPackage("nodomain.freeyourgadget.gadgetbridge") + .putExtra(DeviceAlarmReceiver.EXTRA_MAC_ADDR, device.getAddress()); + } + + private void storeAlarm(final int position, final boolean enabled, final int hour, final int minute, final int repetition, final String title) { + final Alarm alarm = AlarmUtils.createDefaultAlarm(daoSession, device, position); + assertNotNull(alarm); + alarm.setEnabled(enabled); + alarm.setHour(hour); + alarm.setMinute(minute); + alarm.setRepetition(repetition); + alarm.setTitle(title); + DBHelper.store(alarm); + } + + private Alarm getAlarm(final int position) { + final List alarms = DBHelper.getAlarmsWithDefaults(device); + for (final Alarm alarm : alarms) { + if (alarm.getPosition() == position) { + return alarm; + } + } + throw new AssertionError("Alarm at position " + position + " not found"); + } + + private void assertForwardedAlarmUpdate(final Intent intent, final int expectedAlarmCount) { + assertNotNull(intent); + assertEquals(DeviceCommunicationService.class.getName(), intent.getComponent().getClassName()); + assertEquals(DeviceService.ACTION_SET_ALARMS, intent.getAction()); + + final ArrayList alarms = (ArrayList) intent.getSerializableExtra(DeviceService.EXTRA_ALARMS); + assertNotNull(alarms); + assertEquals(expectedAlarmCount, alarms.size()); + } + + private void drainStartedServices() { + while (getNextStartedService() != null) { + // Drain app startup noise so each test can assert only its own service invocation. + } + } + + private Intent getNextStartedService() { + return shadowOf((Application) app).getNextStartedService(); + } + + private static final class ThreeSlotTestDevice extends GBDevice { + private static final DeviceCoordinator COORDINATOR = new ThreeSlotTestCoordinator(); + + private ThreeSlotTestDevice(final String address) { + super(address, "Testie", "Test Alias", "Test Folder", DeviceType.TEST); + } + + @Override + public DeviceCoordinator getDeviceCoordinator() { + return COORDINATOR; + } + } + + private static final class ThreeSlotTestCoordinator extends TestDeviceCoordinator { + @Override + public int getAlarmSlotCount(final GBDevice device) { + return 3; + } + } +}