diff --git a/uhabits-android/src/main/AndroidManifest.xml b/uhabits-android/src/main/AndroidManifest.xml index 9c114d733..d92d66bd6 100644 --- a/uhabits-android/src/main/AndroidManifest.xml +++ b/uhabits-android/src/main/AndroidManifest.xml @@ -217,6 +217,14 @@ + + + + + + diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt index 4f00f4d1f..965fab31f 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt @@ -94,6 +94,7 @@ class HabitsApplication : Application() { taskRunner.execute { reminderScheduler.scheduleAll() widgetUpdater.updateWidgets() + notificationTray.reshowAll() } } 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 c7b6843d0..a95e610dd 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 @@ -72,9 +72,10 @@ class HabitsModule(dbFile: File) { taskRunner: TaskRunner, commandRunner: CommandRunner, preferences: Preferences, - screen: AndroidNotificationTray + screen: AndroidNotificationTray, + habitList: HabitList ): NotificationTray { - return NotificationTray(taskRunner, commandRunner, preferences, screen) + return NotificationTray(taskRunner, commandRunner, preferences, screen, habitList) } @Provides 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 09d8f8b46..271892b9b 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 @@ -66,10 +66,11 @@ class AndroidNotificationTray habit: Habit, notificationId: Int, timestamp: Timestamp, - reminderTime: Long + reminderTime: Long, + silent: Boolean ) { val notificationManager = NotificationManagerCompat.from(context) - val notification = buildNotification(habit, reminderTime, timestamp) + val notification = buildNotification(habit, reminderTime, timestamp, silent = silent) createAndroidNotificationChannel(context) try { notificationManager.notify(notificationId, notification) @@ -83,7 +84,8 @@ class AndroidNotificationTray habit, reminderTime, timestamp, - disableSound = true + disableSound = true, + silent = silent ) notificationManager.notify(notificationId, n) } @@ -94,7 +96,8 @@ class AndroidNotificationTray habit: Habit, reminderTime: Long, timestamp: Timestamp, - disableSound: Boolean = false + disableSound: Boolean = false, + silent: Boolean = false ): Notification { val addRepetitionAction = Action( @@ -132,6 +135,7 @@ class AndroidNotificationTray .setSound(null) .setWhen(reminderTime) .setShowWhen(true) + .setSilent(silent) .setOngoing(preferences.shouldMakeNotificationsSticky()) if (habit.isNumerical) { 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 38ba3a1b4..718e7f535 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 @@ -37,10 +37,6 @@ class ReminderController @Inject constructor( private val notificationTray: NotificationTray, private val preferences: Preferences ) { - fun onBootCompleted() { - reminderScheduler.scheduleAll() - } - fun onShowReminder( habit: Habit, timestamp: Timestamp, 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..a08e817e6 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 @@ -96,7 +96,7 @@ class ReminderReceiver : BroadcastReceiver() { } Intent.ACTION_BOOT_COMPLETED -> { Log.d("ReminderReceiver", "onBootCompleted") - reminderController.onBootCompleted() + // NOTE: Some activity is executed after boot through HabitsApplication, so receiving ACTION_BOOT_COMPLETED is essential. } } } catch (e: RuntimeException) { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/receivers/UpdateReceiver.kt b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/UpdateReceiver.kt new file mode 100644 index 000000000..63add51bc --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/receivers/UpdateReceiver.kt @@ -0,0 +1,14 @@ +package org.isoron.uhabits.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class UpdateReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + // Dummy receiver, relevant code is executed through HabitsApplication. + Log.d("UpdateReceiver", "Update receiver called.") + } +} diff --git a/uhabits-core/build.gradle.kts b/uhabits-core/build.gradle.kts index 15f2615d4..efaf902a5 100644 --- a/uhabits-core/build.gradle.kts +++ b/uhabits-core/build.gradle.kts @@ -19,6 +19,7 @@ plugins { kotlin("multiplatform") + kotlin("plugin.serialization") version "1.7.10" id("org.jlleitschuh.gradle.ktlint") } @@ -30,6 +31,7 @@ kotlin { dependencies { implementation(kotlin("stdlib-common")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.8") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Timestamp.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Timestamp.kt index 2233237c0..74e7e9d10 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Timestamp.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Timestamp.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.core.models +import kotlinx.serialization.Serializable import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.utils.DateFormats.Companion.getCSVDateFormat import org.isoron.uhabits.core.utils.DateFormats.Companion.getDialogDateFormat @@ -29,6 +30,7 @@ import java.util.Date import java.util.GregorianCalendar import java.util.TimeZone +@Serializable data class Timestamp(var unixTime: Long) : Comparable { constructor(cal: GregorianCalendar) : this(cal.timeInMillis) diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt index 1598b2ca1..71308e8a1 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/preferences/Preferences.kt @@ -18,11 +18,18 @@ */ package org.isoron.uhabits.core.preferences +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.isoron.platform.time.DayOfWeek import org.isoron.platform.utils.StringUtils.Companion.joinLongs import org.isoron.platform.utils.StringUtils.Companion.splitLongs +import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.Timestamp +import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.core.ui.ThemeSwitcher import org.isoron.uhabits.core.utils.DateUtils.Companion.getFirstWeekdayNumberAccordingToLocale import java.util.LinkedList @@ -135,6 +142,36 @@ open class Preferences(private val storage: Storage) { storage.putBoolean("pref_short_toggle", enabled) } + internal open fun setActiveNotifications(activeNotifications: Map) { + val activeById = activeNotifications.mapKeys { it.key.id } + val serialized = Json.encodeToString(activeById) + storage.putString("pref_active_notifications", serialized) + } + + internal open fun getActiveNotifications(habitList: HabitList): HashMap { + val serialized = storage.getString("pref_active_notifications", "") + return if (serialized == "") { + HashMap() + } else { + try { + val activeById = Json.decodeFromString( + MapSerializer( + Long.serializer(), + NotificationTray.NotificationData.serializer() + ), + serialized + ) + val activeByHabit = + activeById.mapNotNull { (id, v) -> habitList.getById(id)?.let { it to v } } + activeByHabit.toMap(HashMap()) + } catch (e: IllegalArgumentException) { + HashMap() + } catch (e: SerializationException) { + HashMap() + } + } + } + fun removeListener(listener: Listener) { listeners.remove(listener) } 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 5239ed44b..f1a0abc96 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 @@ -18,17 +18,18 @@ */ package org.isoron.uhabits.core.ui +import kotlinx.serialization.Serializable 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.DeleteHabitsCommand import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitList 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 @@ -38,9 +39,31 @@ class NotificationTray @Inject constructor( private val taskRunner: TaskRunner, private val commandRunner: CommandRunner, private val preferences: Preferences, - private val systemTray: SystemTray + private val systemTray: SystemTray, + private val habitList: HabitList ) : CommandRunner.Listener, Preferences.Listener { - private val active: HashMap = HashMap() + + /** + * A mapping from habits to active notifications, automatically persisting on removal. + */ + private val active = object { + private val m: HashMap = + preferences.getActiveNotifications(habitList) + + val entries get() = m.entries + + operator fun set(habit: Habit, notificationData: NotificationData) { + m[habit] = notificationData + persist() + } + + fun remove(habit: Habit) { + m.remove(habit)?.let { persist() } // persist if changed + } + + fun persist() = preferences.setActiveNotifications(m) + } + fun cancel(habit: Habit) { val notificationId = getNotificationId(habit) systemTray.removeNotification(notificationId) @@ -64,8 +87,7 @@ class NotificationTray @Inject constructor( fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) { val data = NotificationData(timestamp, reminderTime) - active[habit] = data - taskRunner.execute(ShowNotificationTask(habit, data)) + taskRunner.execute(ShowNotificationTask(habit, data, silent = false)) } fun startListening() { @@ -83,9 +105,9 @@ class NotificationTray @Inject constructor( return (id % Int.MAX_VALUE).toInt() } - private fun reshowAll() { + fun reshowAll() { for ((habit, data) in active.entries) { - taskRunner.execute(ShowNotificationTask(habit, data)) + taskRunner.execute(ShowNotificationTask(habit, data, silent = true)) } } @@ -95,18 +117,26 @@ class NotificationTray @Inject constructor( habit: Habit, notificationId: Int, timestamp: Timestamp, - reminderTime: Long + reminderTime: Long, + silent: Boolean = false ) fun log(msg: String) } - internal class NotificationData(val timestamp: Timestamp, val reminderTime: Long) - private inner class ShowNotificationTask(private val habit: Habit, data: NotificationData) : + @Serializable + internal data class NotificationData( + val timestamp: Timestamp, + val reminderTime: Long, + ) + + private inner class ShowNotificationTask( + private val habit: Habit, + private val data: NotificationData, + private val silent: Boolean + ) : Task { var isCompleted = false - private val timestamp: Timestamp = data.timestamp - private val reminderTime: Long = data.reminderTime override fun doInBackground() { isCompleted = habit.isCompletedToday() @@ -122,6 +152,7 @@ class NotificationTray @Inject constructor( habit.id ) ) + active.remove(habit) return } if (!habit.hasReminder()) { @@ -132,6 +163,7 @@ class NotificationTray @Inject constructor( habit.id ) ) + active.remove(habit) return } if (habit.isArchived) { @@ -142,6 +174,7 @@ class NotificationTray @Inject constructor( habit.id ) ) + active.remove(habit) return } if (!shouldShowReminderToday()) { @@ -152,21 +185,33 @@ class NotificationTray @Inject constructor( habit.id ) ) + active.remove(habit) return } systemTray.showNotification( habit, getNotificationId(habit), - timestamp, - reminderTime + data.timestamp, + data.reminderTime, + silent = silent ) + if (silent) { + systemTray.log( + String.format( + Locale.US, + "Showing notification for habit %d silently because it has been shown before.", + habit.id + ) + ) + } + active[habit] = data } private fun shouldShowReminderToday(): Boolean { if (!habit.hasReminder()) return false val reminder = habit.reminder val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray() - val weekday = timestamp.weekday + val weekday = data.timestamp.weekday return reminderDays[weekday] } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt index 52117c181..4be8bedeb 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/preferences/PreferencesTest.kt @@ -25,8 +25,11 @@ import junit.framework.Assert.assertTrue import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.isoron.uhabits.core.BaseUnitTest +import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList +import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp.Companion.ZERO +import org.isoron.uhabits.core.ui.NotificationTray import org.isoron.uhabits.core.ui.ThemeSwitcher import org.junit.Before import org.junit.Test @@ -162,6 +165,31 @@ class PreferencesTest : BaseUnitTest() { assertFalse(prefs.showCompleted) } + @Test + @Throws(Exception::class) + fun testActiveNotifications() { + repeat(5) { habitList.add(fixtures.createEmptyHabit()) } + + // Initially no active notifications + assertThat(prefs.getActiveNotifications(habitList), equalTo(HashMap())) + + // Example map of active notifications + val a = HashMap() + for (i in listOf(0, 1, 3)) { + val habit = habitList.getByPosition(i) + val data = NotificationTray.NotificationData(Timestamp(10000L * i), 200000L * i) + a[habit] = data + } + + // Persist and retrieve active notifications + prefs.setActiveNotifications(a) + val b = prefs.getActiveNotifications(habitList) + + // Assert that persisted and retrieved maps are teh same + assertThat(a.keys, equalTo(b.keys)) + a.forEach { e -> assertThat(b[e.key], equalTo(e.value)) } + } + @Test @Throws(Exception::class) fun testMidnightDelay() { diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/NotificationTrayTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/NotificationTrayTest.kt new file mode 100644 index 000000000..2febcf3f1 --- /dev/null +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/NotificationTrayTest.kt @@ -0,0 +1,125 @@ +package org.isoron.uhabits.core.ui + +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.isoron.uhabits.core.BaseUnitTest +import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitList +import org.isoron.uhabits.core.models.Reminder +import org.isoron.uhabits.core.models.Timestamp +import org.isoron.uhabits.core.models.WeekdayList +import org.isoron.uhabits.core.preferences.Preferences +import org.isoron.uhabits.core.preferences.Preferences.Storage +import org.junit.Before +import org.junit.Test + +class NotificationTrayTest : BaseUnitTest() { + private val systemTray = object : NotificationTray.SystemTray { + override fun removeNotification(notificationId: Int) {} + + override fun showNotification( + habit: Habit, + notificationId: Int, + timestamp: Timestamp, + reminderTime: Long, + silent: Boolean + ) { + } + + override fun log(msg: String) {} + } + + private var preferences = MockPreferences() + private lateinit var notificationTray: NotificationTray + + class DummyStorage : Storage { + override fun clear() { + throw NotImplementedError("Mock implementation missing") + } + + override fun getBoolean(key: String, defValue: Boolean): Boolean { + throw NotImplementedError("Mock implementation missing") + } + + override fun getInt(key: String, defValue: Int): Int { + throw NotImplementedError("Mock implementation missing") + } + + override fun getLong(key: String, defValue: Long): Long { + throw NotImplementedError("Mock implementation missing") + } + + override fun getString(key: String, defValue: String): String { + throw NotImplementedError("Mock implementation missing") + } + + override fun onAttached(preferences: Preferences) { + } + + override fun putBoolean(key: String, value: Boolean) { + throw NotImplementedError("Mock implementation missing") + } + + override fun putInt(key: String, value: Int) { + throw NotImplementedError("Mock implementation missing") + } + + override fun putLong(key: String, value: Long) { + throw NotImplementedError("Mock implementation missing") + } + + override fun putString(key: String, value: String) { + throw NotImplementedError("Mock implementation missing") + } + + override fun remove(key: String) { + throw NotImplementedError("Mock implementation missing") + } + } + + class MockPreferences : Preferences(DummyStorage()) { + private var activeNotifications: HashMap = + HashMap() + + override fun setActiveNotifications(activeNotifications: Map) { + this.activeNotifications = HashMap(activeNotifications) + } + + override fun getActiveNotifications(habitList: HabitList): HashMap { + return activeNotifications + } + } + + @Before + @Throws(Exception::class) + override fun setUp() { + super.setUp() + notificationTray = + NotificationTray(taskRunner, commandRunner, preferences, systemTray, habitList) + } + + @Test + @Throws(Exception::class) + fun testShow() { + // Show a reminder for a habit + val habit = fixtures.createEmptyHabit() + habit.reminder = Reminder(8, 30, WeekdayList.EVERY_DAY) + val timestamp = Timestamp(System.currentTimeMillis()) + val reminderTime = System.currentTimeMillis() + notificationTray.show(habit, timestamp, reminderTime) + + // Verify that the active notifications include exactly the one shown reminder + // TODO are we guaranteed that task has executed? + assertThat(preferences.getActiveNotifications(habitList).size, equalTo(1)) + assertThat( + preferences.getActiveNotifications(habitList)[habit], + equalTo(NotificationTray.NotificationData(timestamp, reminderTime)) + ) + + // Remove the reminder from the notification tray and verify that active notifications are empty + notificationTray.cancel(habit) + assertThat(preferences.getActiveNotifications(habitList).size, equalTo(0)) + + // TODO test cases where reminders should be removed (e.g. reshowAll) + } +}