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