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; + } + } +}