diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt index f9cd0c47d..dab076b8b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsActivity.kt @@ -181,6 +181,16 @@ class ListHabitsActivity : AppCompatActivity(), Preferences.Listener { component.listHabitsBehavior.onEdit(habit, Timestamp(timestamp)) } } + intent.getLongExtra("CLEAR_NOTIFICATION_HABIT_ID", -1).takeIf { it != -1L }?.let { id -> + val dismissHabit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id) + if (dismissHabit != null) { + appComponent.reminderController.onDismiss(dismissHabit) + } else { + val dismissHabitGroup = appComponent.habitGroupList.getById(id)!! + appComponent.reminderController.onDismiss(dismissHabitGroup) + } + } + intent = null } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt index bac2faa9b..fd687604e 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt @@ -63,9 +63,10 @@ class HabitsModule(dbFile: File) { sys: IntentScheduler, commandRunner: CommandRunner, habitList: HabitList, + habitGroupList: HabitGroupList, widgetPreferences: WidgetPreferences ): ReminderScheduler { - return ReminderScheduler(commandRunner, habitList, sys, widgetPreferences) + return ReminderScheduler(commandRunner, habitList, habitGroupList, sys, widgetPreferences) } @Provides diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt index 047474d67..55791ad1e 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/IntentScheduler.kt @@ -29,6 +29,7 @@ import android.os.Build import android.util.Log import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.reminders.ReminderScheduler.SchedulerResult import org.isoron.uhabits.core.reminders.ReminderScheduler.SystemScheduler import org.isoron.uhabits.core.utils.DateFormats @@ -75,6 +76,16 @@ class IntentScheduler return schedule(reminderTime, intent, RTC_WAKEUP) } + override fun scheduleShowReminder( + reminderTime: Long, + habitGroup: HabitGroup, + timestamp: Long + ): SchedulerResult { + val intent = pendingIntents.showReminder(habitGroup, reminderTime, timestamp) + logReminderScheduled(habitGroup, reminderTime) + return schedule(reminderTime, intent, RTC_WAKEUP) + } + override fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult { val intent = pendingIntents.updateWidgets() return schedule(updateTime, intent, RTC) @@ -94,4 +105,15 @@ class IntentScheduler String.format("Setting alarm (%s): %s", time, name) ) } + + private fun logReminderScheduled(habitGroup: HabitGroup, reminderTime: Long) { + val min = min(5, habitGroup.name.length) + val name = habitGroup.name.substring(0, min) + val df = DateFormats.getBackupDateFormat() + val time = df.format(Date(reminderTime)) + Log.i( + "ReminderHelper", + String.format("Setting alarm (%s): %s", time, name) + ) + } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt b/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt index 038cfe3a4..03775abeb 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/intents/PendingIntentFactory.kt @@ -33,6 +33,7 @@ import org.isoron.uhabits.activities.habits.list.ListHabitsActivity import org.isoron.uhabits.activities.habits.show.ShowHabitActivity import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.inject.AppContext import org.isoron.uhabits.receivers.ReminderReceiver @@ -69,6 +70,17 @@ class PendingIntentFactory FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) + fun dismissNotification(habitGroup: HabitGroup): PendingIntent = + getBroadcast( + context, + 0, + Intent(context, ReminderReceiver::class.java).apply { + action = WidgetReceiver.ACTION_DISMISS_REMINDER + data = Uri.parse(habitGroup.uriString) + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + fun removeRepetition(habit: Habit, timestamp: Timestamp?): PendingIntent = getBroadcast( context, @@ -92,6 +104,17 @@ class PendingIntentFactory ) .getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!! + fun showHabitGroup(habitGroup: HabitGroup): PendingIntent = + androidx.core.app.TaskStackBuilder + .create(context) + .addNextIntentWithParentStack( + intentFactory.startShowHabitGroupActivity( + context, + habitGroup + ) + ) + .getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)!! + fun showHabitTemplate(): PendingIntent { return getActivity( context, @@ -123,6 +146,23 @@ class PendingIntentFactory FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) + fun showReminder( + habitGroup: HabitGroup, + reminderTime: Long?, + timestamp: Long + ): PendingIntent = + getBroadcast( + context, + (habitGroup.id!! % Integer.MAX_VALUE).toInt() + 1, + Intent(context, ReminderReceiver::class.java).apply { + action = ReminderReceiver.ACTION_SHOW_REMINDER + data = Uri.parse(habitGroup.uriString) + putExtra("timestamp", timestamp) + putExtra("reminderTime", reminderTime) + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + fun snoozeNotification(habit: Habit): PendingIntent = getBroadcast( context, @@ -134,6 +174,17 @@ class PendingIntentFactory FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) + fun snoozeNotification(habitGroup: HabitGroup): PendingIntent = + getBroadcast( + context, + 0, + Intent(context, ReminderReceiver::class.java).apply { + data = Uri.parse(habitGroup.uriString) + action = ReminderReceiver.ACTION_SNOOZE_REMINDER + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent = getBroadcast( context, @@ -185,6 +236,26 @@ class PendingIntentFactory putExtra("timestamp", timestamp.unixTime) } + fun showHabitList(): PendingIntent { + return getActivity( + context, + 1, + Intent(context, ListHabitsActivity::class.java), + getIntentTemplateFlags() + ) + } + + fun showHabitListWithNotificationClear(id: Long): PendingIntent { + return getActivity( + context, + 0, + Intent(context, ListHabitsActivity::class.java).apply { + putExtra("CLEAR_NOTIFICATION_HABIT_ID", id) + }, + getIntentTemplateFlags() + ) + } + private fun getIntentTemplateFlags(): Int { var flags = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt index a63982358..bb28b5ad3 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/AndroidNotificationTray.kt @@ -35,6 +35,7 @@ import androidx.core.app.NotificationManagerCompat import org.isoron.uhabits.R import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.ui.NotificationTray @@ -90,6 +91,34 @@ class AndroidNotificationTray active.add(notificationId) } + override fun showNotification( + habitGroup: HabitGroup, + notificationId: Int, + timestamp: Timestamp, + reminderTime: Long + ) { + val notificationManager = NotificationManagerCompat.from(context) + val notification = buildNotification(habitGroup, reminderTime, timestamp) + createAndroidNotificationChannel(context) + try { + notificationManager.notify(notificationId, notification) + } catch (e: RuntimeException) { + // Some Xiaomi phones produce a RuntimeException if custom notification sounds are used. + Log.i( + "AndroidNotificationTray", + "Failed to show notification. Retrying without sound." + ) + val n = buildNotification( + habitGroup, + reminderTime, + timestamp, + disableSound = true + ) + notificationManager.notify(notificationId, n) + } + active.add(notificationId) + } + fun buildNotification( habit: Habit, reminderTime: Long, @@ -163,6 +192,58 @@ class AndroidNotificationTray return builder.build() } + fun buildNotification( + habitGroup: HabitGroup, + reminderTime: Long, + timestamp: Timestamp, + disableSound: Boolean = false + ): Notification { + val enterAction = Action( + R.drawable.ic_action_check, + context.getString(R.string.enter), + pendingIntents.showHabitListWithNotificationClear(habitGroup.id!!) + ) + + val wearableBg = decodeResource(context.resources, R.drawable.stripe) + + // Even though the set of actions is the same on the phone and + // on the watch, Pebble requires us to add them to the + // WearableExtender. + val wearableExtender = WearableExtender().setBackground(wearableBg) + + val defaultText = context.getString(R.string.default_reminder_question) + val builder = Builder(context, REMINDERS_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(habitGroup.name) + .setContentText(if (habitGroup.question.isBlank()) defaultText else habitGroup.question) + .setContentIntent(pendingIntents.showHabitGroup(habitGroup)) + .setDeleteIntent(pendingIntents.dismissNotification(habitGroup)) + .setSound(null) + .setWhen(reminderTime) + .setShowWhen(true) + .setOngoing(preferences.shouldMakeNotificationsSticky()) + + wearableExtender.addAction(enterAction) + builder.addAction(enterAction) + + if (!disableSound) { + builder.setSound(ringtoneManager.getURI()) + } + + if (SDK_INT < Build.VERSION_CODES.S) { + val snoozeAction = Action( + R.drawable.ic_action_snooze, + context.getString(R.string.snooze), + pendingIntents.snoozeNotification(habitGroup) + ) + wearableExtender.addAction(snoozeAction) + builder.addAction(snoozeAction) + } + + builder.extend(wearableExtender) + return builder.build() + } + companion object { private const val REMINDERS_CHANNEL_ID = "REMINDERS" fun createAndroidNotificationChannel(context: Context) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt index f7e532984..23a9ab9db 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/notifications/SnoozeDelayPickerActivity.kt @@ -33,6 +33,7 @@ import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.R import org.isoron.uhabits.activities.AndroidThemeSwitcher import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.ui.views.DarkTheme import org.isoron.uhabits.core.ui.views.LightTheme import org.isoron.uhabits.receivers.ReminderController @@ -41,6 +42,7 @@ import java.util.Calendar class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { private var habit: Habit? = null + private var habitGroup: HabitGroup? = null private var reminderController: ReminderController? = null private var dialog: AlertDialog? = null private var androidColor: Int = 0 @@ -58,10 +60,13 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { if (data == null) { finish() } else { - habit = appComponent.habitList.getById(ContentUris.parseId(data)) + val id = ContentUris.parseId(data) + habit = appComponent.habitList.getById(id) ?: appComponent.habitGroupList.getHabitByID(id) + habitGroup = appComponent.habitGroupList.getById(id) } - if (habit == null) finish() - androidColor = themeSwitcher.currentTheme.color(habit!!.color).toInt() + if (habit == null && habitGroup == null) finish() + val color = habit?.color ?: habitGroup!!.color + androidColor = themeSwitcher.currentTheme.color(color).toInt() reminderController = appComponent.reminderController dialog = AlertDialog.Builder(this) .setTitle(R.string.select_snooze_delay) @@ -87,7 +92,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { val calendar = Calendar.getInstance() val dialog = TimePickerDialog.newInstance( { view: RadialPickerLayout?, hour: Int, minute: Int -> - reminderController!!.onSnoozeTimePicked(habit, hour, minute) + if (habit != null) { + reminderController!!.onSnoozeTimePicked(habit, hour, minute) + } else { + reminderController!!.onSnoozeTimePicked(habitGroup, hour, minute) + } finish() }, calendar[Calendar.HOUR_OF_DAY], @@ -101,7 +110,11 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener { override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { val snoozeValues = resources.getIntArray(R.array.snooze_picker_values) if (snoozeValues[position] >= 0) { - reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position]) + if (habit != null) { + reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position]) + } else { + reminderController!!.onSnoozeDelayPicked(habitGroup!!, snoozeValues[position]) + } finish() } else { showTimePicker() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt index 4937dbc74..925fe5e7d 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderController.kt @@ -23,6 +23,7 @@ import android.content.Intent import android.net.Uri import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.reminders.ReminderScheduler @@ -50,21 +51,45 @@ class ReminderController @Inject constructor( reminderScheduler.scheduleAll() } + fun onShowReminder( + habitGroup: HabitGroup, + timestamp: Timestamp, + reminderTime: Long + ) { + notificationTray.show(habitGroup, timestamp, reminderTime) + reminderScheduler.scheduleAll() + } + fun onSnoozePressed(habit: Habit, context: Context) { showSnoozeDelayPicker(habit, context) } + fun onSnoozePressed(habitGroup: HabitGroup, context: Context) { + showSnoozeDelayPicker(habitGroup, context) + } + fun onSnoozeDelayPicked(habit: Habit, delayInMinutes: Int) { reminderScheduler.snoozeReminder(habit, delayInMinutes.toLong()) notificationTray.cancel(habit) } + fun onSnoozeDelayPicked(habitGroup: HabitGroup, delayInMinutes: Int) { + reminderScheduler.snoozeReminder(habitGroup, delayInMinutes.toLong()) + notificationTray.cancel(habitGroup) + } + fun onSnoozeTimePicked(habit: Habit?, hour: Int, minute: Int) { val time: Long = getUpcomingTimeInMillis(hour, minute) reminderScheduler.scheduleAtTime(habit!!, time) notificationTray.cancel(habit) } + fun onSnoozeTimePicked(habitGroup: HabitGroup?, hour: Int, minute: Int) { + val time: Long = getUpcomingTimeInMillis(hour, minute) + reminderScheduler.scheduleAtTime(habitGroup!!, time) + notificationTray.cancel(habitGroup) + } + fun onDismiss(habit: Habit) { if (preferences.shouldMakeNotificationsSticky()) { // This is a workaround to keep sticky notifications non-dismissible in Android 14+. @@ -75,6 +100,10 @@ class ReminderController @Inject constructor( } } + fun onDismiss(habitGroup: HabitGroup) { + notificationTray.cancel(habitGroup) + } + private fun showSnoozeDelayPicker(habit: Habit, context: Context) { context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) val intent = Intent(context, SnoozeDelayPickerActivity::class.java) @@ -82,4 +111,12 @@ class ReminderController @Inject constructor( intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } + + private fun showSnoozeDelayPicker(habitGroup: HabitGroup, context: Context) { + context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) + val intent = Intent(context, SnoozeDelayPickerActivity::class.java) + intent.data = Uri.parse(habitGroup.uriString) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt index 6eb10dc7e..a5e6db51a 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/ReminderReceiver.kt @@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT import android.util.Log import org.isoron.uhabits.HabitsApplication import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayWithOffset @@ -44,52 +45,81 @@ class ReminderReceiver : BroadcastReceiver() { val app = context.applicationContext as HabitsApplication val appComponent = app.component val habits = appComponent.habitList + val habitGroups = appComponent.habitGroupList val reminderController = appComponent.reminderController Log.i(TAG, String.format("Received intent: %s", intent.toString())) var habit: Habit? = null + var habitGroup: HabitGroup? = null + var id: Long? = null + var type: String? = null val today: Long = getStartOfTodayWithOffset() val data = intent.data - if (data != null) habit = habits.getById(ContentUris.parseId(data)) + if (data != null) { + type = data.pathSegments[0] + id = ContentUris.parseId(data) + when (type) { + "habit" -> habit = habits.getById(id) ?: habitGroups.getHabitByID(id) + "habitgroup" -> habitGroup = habitGroups.getById(id) + } + } val timestamp = intent.getLongExtra("timestamp", today) val reminderTime = intent.getLongExtra("reminderTime", today) try { when (intent.action) { ACTION_SHOW_REMINDER -> { - if (habit == null) return + if (id == null) return Log.d( "ReminderReceiver", String.format( - "onShowReminder habit=%d timestamp=%d reminderTime=%d", - habit.id, + "onShowReminder %s=%d timestamp=%d reminderTime=%d", + type, + id, timestamp, reminderTime ) ) - reminderController.onShowReminder( - habit, - Timestamp(timestamp), - reminderTime - ) + if (habit != null) { + reminderController.onShowReminder( + habit, + Timestamp(timestamp), + reminderTime + ) + } else { + reminderController.onShowReminder( + habitGroup!!, + Timestamp(timestamp), + reminderTime + ) + } } ACTION_DISMISS_REMINDER -> { - if (habit == null) return - Log.d("ReminderReceiver", String.format("onDismiss habit=%d", habit.id)) - reminderController.onDismiss(habit) + if (id == null) return + Log.d("ReminderReceiver", String.format("onDismiss %s=%d", type, id)) + if (habit != null) { + reminderController.onDismiss(habit) + } else { + reminderController.onDismiss(habitGroup!!) + } } ACTION_SNOOZE_REMINDER -> { - if (habit == null) return + if (id == null) return if (SDK_INT < Build.VERSION_CODES.S) { Log.d( "ReminderReceiver", - String.format("onSnoozePressed habit=%d", habit.id) + String.format("onSnoozePressed %s=%d", type, id) ) - reminderController.onSnoozePressed(habit, context) + if (habit != null) { + reminderController.onSnoozePressed(habit, context) + } else { + reminderController.onSnoozePressed(habitGroup!!, context) + } } else { Log.w( "ReminderReceiver", String.format( - "onSnoozePressed habit=%d, should be deactivated in recent versions.", - habit.id + "onSnoozePressed %s=%d, should be deactivated in recent versions.", + type, + id ) ) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt index 83f02dcc5..70f75f736 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt @@ -43,7 +43,7 @@ data class HabitGroup( var observable = ModelObservable() val uriString: String - get() = "content://org.isoron.uhabits/habit/$id" + get() = "content://org.isoron.uhabits/habitgroup/$id" fun hasReminder(): Boolean = reminder != null diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt index bae15bb00..22611932f 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/reminders/ReminderScheduler.kt @@ -24,6 +24,8 @@ import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.preferences.WidgetPreferences @@ -39,6 +41,7 @@ import javax.inject.Inject class ReminderScheduler @Inject constructor( private val commandRunner: CommandRunner, private val habitList: HabitList, + private val habitGroupList: HabitGroupList, private val sys: SystemScheduler, private val widgetPreferences: WidgetPreferences ) : CommandRunner.Listener { @@ -83,6 +86,40 @@ class ReminderScheduler @Inject constructor( scheduleAtTime(habit, reminderTime) } + @Synchronized + fun schedule(habitGroup: HabitGroup) { + if (habitGroup.id == null) { + sys.log("ReminderScheduler", "Habit group has null id. Returning.") + return + } + if (!habitGroup.hasReminder()) { + sys.log("ReminderScheduler", "habit group=" + habitGroup.id + " has no reminder. Skipping.") + return + } + var reminderTime = Objects.requireNonNull(habitGroup.reminder)!!.timeInMillis + val snoozeReminderTime = widgetPreferences.getSnoozeTime(habitGroup.id!!) + if (snoozeReminderTime != 0L) { + val now = applyTimezone(getLocalTime()) + sys.log( + "ReminderScheduler", + String.format( + Locale.US, + "Habit group %d has been snoozed until %d", + habitGroup.id, + snoozeReminderTime + ) + ) + if (snoozeReminderTime > now) { + sys.log("ReminderScheduler", "Snooze time is in the future. Accepting.") + reminderTime = snoozeReminderTime + } else { + sys.log("ReminderScheduler", "Snooze time is in the past. Discarding.") + widgetPreferences.removeSnoozeTime(habitGroup.id!!) + } + } + scheduleAtTime(habitGroup, reminderTime) + } + @Synchronized fun scheduleAtTime(habit: Habit, reminderTime: Long) { sys.log("ReminderScheduler", "Scheduling alarm for habit=" + habit.id) @@ -108,16 +145,48 @@ class ReminderScheduler @Inject constructor( sys.scheduleShowReminder(reminderTime, habit, timestamp) } + @Synchronized + fun scheduleAtTime(habitGroup: HabitGroup, reminderTime: Long) { + sys.log("ReminderScheduler", "Scheduling alarm for habit group=" + habitGroup.id) + if (!habitGroup.hasReminder()) { + sys.log("ReminderScheduler", "habit group=" + habitGroup.id + " has no reminder. Skipping.") + return + } + if (habitGroup.isArchived) { + sys.log("ReminderScheduler", "habit group=" + habitGroup.id + " is archived. Skipping.") + return + } + val timestamp = getStartOfDayWithOffset(removeTimezone(reminderTime)) + sys.log( + "ReminderScheduler", + String.format( + Locale.US, + "reminderTime=%d removeTimezone=%d timestamp=%d", + reminderTime, + removeTimezone(reminderTime), + timestamp + ) + ) + sys.scheduleShowReminder(reminderTime, habitGroup, timestamp) + } + @Synchronized fun scheduleAll() { sys.log("ReminderScheduler", "Scheduling all alarms") val reminderHabits = habitList.getFiltered(HabitMatcher.WITH_ALARM) + val reminderSubHabits = habitGroupList.map { it.habitList.getFiltered(HabitMatcher.WITH_ALARM) }.flatten() + val reminderHabitGroups = habitGroupList.getFiltered(HabitMatcher.WITH_ALARM) for (habit in reminderHabits) schedule(habit) + for (habit in reminderSubHabits) schedule(habit) + for (hgr in reminderHabitGroups) schedule(hgr) } @Synchronized fun hasHabitsWithReminders(): Boolean { - return !habitList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty + if (!habitList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty) return true + if (habitGroupList.map { it.habitList.getFiltered(HabitMatcher.WITH_ALARM) }.flatten().isNotEmpty()) return true + if (!habitGroupList.getFiltered(HabitMatcher.WITH_ALARM).isEmpty) return true + return false } @Synchronized @@ -138,6 +207,14 @@ class ReminderScheduler @Inject constructor( schedule(habit) } + @Synchronized + fun snoozeReminder(habitGroup: HabitGroup, minutes: Long) { + val now = applyTimezone(getLocalTime()) + val snoozedUntil = now + minutes * 60 * 1000 + widgetPreferences.setSnoozeTime(habitGroup.id!!, snoozedUntil) + schedule(habitGroup) + } + interface SystemScheduler { fun scheduleShowReminder( reminderTime: Long, @@ -145,6 +222,12 @@ class ReminderScheduler @Inject constructor( timestamp: Long ): SchedulerResult + fun scheduleShowReminder( + reminderTime: Long, + habitGroup: HabitGroup, + timestamp: Long + ): SchedulerResult + fun scheduleWidgetUpdate(updateTime: Long): SchedulerResult? fun log(componentName: String, msg: String) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt index da5bb018a..0c32b7b7b 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/NotificationTray.kt @@ -22,13 +22,14 @@ import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateRepetitionCommand +import org.isoron.uhabits.core.commands.DeleteHabitGroupsCommand import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.TaskRunner -import java.util.HashMap import java.util.Locale import java.util.Objects import javax.inject.Inject @@ -40,11 +41,18 @@ class NotificationTray @Inject constructor( private val preferences: Preferences, private val systemTray: SystemTray ) : CommandRunner.Listener, Preferences.Listener { - private val active: HashMap = HashMap() + private val activeHabits: HashMap = HashMap() + private val activeHabitGroups: HashMap = HashMap() fun cancel(habit: Habit) { val notificationId = getNotificationId(habit) systemTray.removeNotification(notificationId) - active.remove(habit) + activeHabits.remove(habit) + } + + fun cancel(habitGroup: HabitGroup) { + val notificationId = getNotificationId(habitGroup) + systemTray.removeNotification(notificationId) + activeHabitGroups.remove(habitGroup) } override fun onCommandFinished(command: Command) { @@ -56,6 +64,13 @@ class NotificationTray @Inject constructor( val (_, deleted) = command for (habit in deleted) cancel(habit) } + if (command is DeleteHabitGroupsCommand) { + val (_, deletedGroups) = command + for (hgr in deletedGroups) { + for (h in hgr.habitList) cancel(h) + cancel(hgr) + } + } } override fun onNotificationsChanged() { @@ -64,10 +79,16 @@ class NotificationTray @Inject constructor( fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) { val data = NotificationData(timestamp, reminderTime) - active[habit] = data + activeHabits[habit] = data taskRunner.execute(ShowNotificationTask(habit, data)) } + fun show(habitGroup: HabitGroup, timestamp: Timestamp, reminderTime: Long) { + val data = NotificationData(timestamp, reminderTime) + activeHabitGroups[habitGroup] = data + taskRunner.execute(ShowNotificationTask(habitGroup, data)) + } + fun startListening() { commandRunner.addListener(this) preferences.addListener(this) @@ -83,14 +104,22 @@ class NotificationTray @Inject constructor( return (id % Int.MAX_VALUE).toInt() } + private fun getNotificationId(habitGroup: HabitGroup): Int { + val id = habitGroup.id ?: return 0 + return (id % Int.MAX_VALUE).toInt() + } + private fun reshowAll() { - for ((habit, data) in active.entries) { + for ((habit, data) in activeHabits.entries) { taskRunner.execute(ShowNotificationTask(habit, data)) } + for ((habitGroup, data) in activeHabitGroups.entries) { + taskRunner.execute(ShowNotificationTask(habitGroup, data)) + } } fun reshow(habit: Habit) { - active[habit]?.let { + activeHabits[habit]?.let { taskRunner.execute(ShowNotificationTask(habit, it)) } } @@ -104,48 +133,79 @@ class NotificationTray @Inject constructor( reminderTime: Long ) + fun showNotification( + habitGroup: HabitGroup, + notificationId: Int, + timestamp: Timestamp, + reminderTime: Long + ) + fun log(msg: String) } internal class NotificationData(val timestamp: Timestamp, val reminderTime: Long) - private inner class ShowNotificationTask(private val habit: Habit, data: NotificationData) : - Task { + private inner class ShowNotificationTask private constructor( + private val habit: Habit? = null, + private val habitGroup: HabitGroup? = null, + data: NotificationData + ) : Task { + // Secondary constructor for Habit + constructor(habit: Habit, data: NotificationData) : this(habit, null, data) + + // Secondary constructor for HabitGroup + constructor(habitGroup: HabitGroup, data: NotificationData) : this(null, habitGroup, data) + var isCompleted = false private val timestamp: Timestamp = data.timestamp private val reminderTime: Long = data.reminderTime + private val type = if (habit != null) "habit" else "habitgroup" + private val id = habit?.id ?: habitGroup?.id + private val hasReminder = habit?.hasReminder() ?: habitGroup!!.hasReminder() + private val isArchived = habit?.isArchived ?: habitGroup!!.isArchived + override fun doInBackground() { - isCompleted = habit.isCompletedToday() + isCompleted = habit?.isCompletedToday() ?: habitGroup!!.isCompletedToday() } override fun onPostExecute() { - systemTray.log("Showing notification for habit=" + habit.id) + systemTray.log( + String.format( + Locale.US, + "Showing notification for %s=%d", + type, + id + ) + ) if (isCompleted) { systemTray.log( String.format( Locale.US, - "Habit %d already checked. Skipping.", - habit.id + "%s %d already checked. Skipping.", + type, + id ) ) return } - if (!habit.hasReminder()) { + if (!hasReminder) { systemTray.log( String.format( Locale.US, - "Habit %d does not have a reminder. Skipping.", - habit.id + "%s %d does not have a reminder. Skipping.", + type, + id ) ) return } - if (habit.isArchived) { + if (isArchived) { systemTray.log( String.format( Locale.US, - "Habit %d is archived. Skipping.", - habit.id + "%s %d is archived. Skipping.", + type, + id ) ) return @@ -154,23 +214,33 @@ class NotificationTray @Inject constructor( systemTray.log( String.format( Locale.US, - "Habit %d not supposed to run today. Skipping.", - habit.id + "%s %d not supposed to run today. Skipping.", + type, + id ) ) return } - systemTray.showNotification( - habit, - getNotificationId(habit), - timestamp, - reminderTime - ) + if (habit != null) { + systemTray.showNotification( + habit, + getNotificationId(habit), + timestamp, + reminderTime + ) + } else { + systemTray.showNotification( + habitGroup!!, + getNotificationId(habitGroup), + timestamp, + reminderTime + ) + } } private fun shouldShowReminderToday(): Boolean { - if (!habit.hasReminder()) return false - val reminder = habit.reminder + if (!hasReminder) return false + val reminder = habit?.reminder ?: habitGroup!!.reminder val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray() val weekday = timestamp.weekday return reminderDays[weekday]