pull/1509/merge
Felix Wiemuth 1 year ago committed by GitHub
commit c974b19330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -218,6 +218,14 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name=".receivers.UpdateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name=".receivers.WidgetReceiver" <receiver android:name=".receivers.WidgetReceiver"
android:exported="true" android:exported="true"
android:permission="false"> android:permission="false">

@ -94,6 +94,7 @@ class HabitsApplication : Application() {
taskRunner.execute { taskRunner.execute {
reminderScheduler.scheduleAll() reminderScheduler.scheduleAll()
widgetUpdater.updateWidgets() widgetUpdater.updateWidgets()
notificationTray.reshowAll()
} }
} }

@ -72,9 +72,10 @@ class HabitsModule(dbFile: File) {
taskRunner: TaskRunner, taskRunner: TaskRunner,
commandRunner: CommandRunner, commandRunner: CommandRunner,
preferences: Preferences, preferences: Preferences,
screen: AndroidNotificationTray screen: AndroidNotificationTray,
habitList: HabitList
): NotificationTray { ): NotificationTray {
return NotificationTray(taskRunner, commandRunner, preferences, screen) return NotificationTray(taskRunner, commandRunner, preferences, screen, habitList)
} }
@Provides @Provides

@ -66,10 +66,11 @@ class AndroidNotificationTray
habit: Habit, habit: Habit,
notificationId: Int, notificationId: Int,
timestamp: Timestamp, timestamp: Timestamp,
reminderTime: Long reminderTime: Long,
silent: Boolean
) { ) {
val notificationManager = NotificationManagerCompat.from(context) val notificationManager = NotificationManagerCompat.from(context)
val notification = buildNotification(habit, reminderTime, timestamp) val notification = buildNotification(habit, reminderTime, timestamp, silent = silent)
createAndroidNotificationChannel(context) createAndroidNotificationChannel(context)
try { try {
notificationManager.notify(notificationId, notification) notificationManager.notify(notificationId, notification)
@ -83,7 +84,8 @@ class AndroidNotificationTray
habit, habit,
reminderTime, reminderTime,
timestamp, timestamp,
disableSound = true disableSound = true,
silent = silent
) )
notificationManager.notify(notificationId, n) notificationManager.notify(notificationId, n)
} }
@ -94,7 +96,8 @@ class AndroidNotificationTray
habit: Habit, habit: Habit,
reminderTime: Long, reminderTime: Long,
timestamp: Timestamp, timestamp: Timestamp,
disableSound: Boolean = false disableSound: Boolean = false,
silent: Boolean = false
): Notification { ): Notification {
val addRepetitionAction = Action( val addRepetitionAction = Action(
R.drawable.ic_action_check, R.drawable.ic_action_check,
@ -131,6 +134,7 @@ class AndroidNotificationTray
.setSound(null) .setSound(null)
.setWhen(reminderTime) .setWhen(reminderTime)
.setShowWhen(true) .setShowWhen(true)
.setSilent(silent)
.setOngoing(preferences.shouldMakeNotificationsSticky()) .setOngoing(preferences.shouldMakeNotificationsSticky())
if (habit.isNumerical) { if (habit.isNumerical) {

@ -37,10 +37,6 @@ class ReminderController @Inject constructor(
private val notificationTray: NotificationTray, private val notificationTray: NotificationTray,
private val preferences: Preferences private val preferences: Preferences
) { ) {
fun onBootCompleted() {
reminderScheduler.scheduleAll()
}
fun onShowReminder( fun onShowReminder(
habit: Habit, habit: Habit,
timestamp: Timestamp, timestamp: Timestamp,

@ -96,7 +96,7 @@ class ReminderReceiver : BroadcastReceiver() {
} }
Intent.ACTION_BOOT_COMPLETED -> { Intent.ACTION_BOOT_COMPLETED -> {
Log.d("ReminderReceiver", "onBootCompleted") 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) { } catch (e: RuntimeException) {

@ -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.")
}
}

@ -19,6 +19,7 @@
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
kotlin("plugin.serialization") version "1.7.10"
id("org.jlleitschuh.gradle.ktlint") id("org.jlleitschuh.gradle.ktlint")
} }
@ -31,6 +32,7 @@ kotlin {
dependencies { dependencies {
implementation(kotlin("stdlib-common")) implementation(kotlin("stdlib-common"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.8") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0")
} }
} }

@ -18,6 +18,7 @@
*/ */
package org.isoron.uhabits.core.models package org.isoron.uhabits.core.models
import kotlinx.serialization.Serializable
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.utils.DateFormats.Companion.getCSVDateFormat import org.isoron.uhabits.core.utils.DateFormats.Companion.getCSVDateFormat
import org.isoron.uhabits.core.utils.DateFormats.Companion.getDialogDateFormat import org.isoron.uhabits.core.utils.DateFormats.Companion.getDialogDateFormat
@ -29,6 +30,7 @@ import java.util.Date
import java.util.GregorianCalendar import java.util.GregorianCalendar
import java.util.TimeZone import java.util.TimeZone
@Serializable
data class Timestamp(var unixTime: Long) : Comparable<Timestamp> { data class Timestamp(var unixTime: Long) : Comparable<Timestamp> {
constructor(cal: GregorianCalendar) : this(cal.timeInMillis) constructor(cal: GregorianCalendar) : this(cal.timeInMillis)

@ -18,11 +18,18 @@
*/ */
package org.isoron.uhabits.core.preferences 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.time.DayOfWeek
import org.isoron.platform.utils.StringUtils.Companion.joinLongs import org.isoron.platform.utils.StringUtils.Companion.joinLongs
import org.isoron.platform.utils.StringUtils.Companion.splitLongs 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.HabitList
import org.isoron.uhabits.core.models.Timestamp 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.ui.ThemeSwitcher
import org.isoron.uhabits.core.utils.DateUtils.Companion.getFirstWeekdayNumberAccordingToLocale import org.isoron.uhabits.core.utils.DateUtils.Companion.getFirstWeekdayNumberAccordingToLocale
import java.util.LinkedList import java.util.LinkedList
@ -135,6 +142,36 @@ open class Preferences(private val storage: Storage) {
storage.putBoolean("pref_short_toggle", enabled) storage.putBoolean("pref_short_toggle", enabled)
} }
internal open fun setActiveNotifications(activeNotifications: Map<Habit, NotificationTray.NotificationData>) {
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<Habit, NotificationTray.NotificationData> {
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) { fun removeListener(listener: Listener) {
listeners.remove(listener) listeners.remove(listener)
} }

@ -18,17 +18,18 @@
*/ */
package org.isoron.uhabits.core.ui package org.isoron.uhabits.core.ui
import kotlinx.serialization.Serializable
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.Command import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.commands.DeleteHabitsCommand
import org.isoron.uhabits.core.models.Habit 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
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.tasks.TaskRunner
import java.util.HashMap
import java.util.Locale import java.util.Locale
import java.util.Objects import java.util.Objects
import javax.inject.Inject import javax.inject.Inject
@ -38,9 +39,31 @@ class NotificationTray @Inject constructor(
private val taskRunner: TaskRunner, private val taskRunner: TaskRunner,
private val commandRunner: CommandRunner, private val commandRunner: CommandRunner,
private val preferences: Preferences, private val preferences: Preferences,
private val systemTray: SystemTray private val systemTray: SystemTray,
private val habitList: HabitList
) : CommandRunner.Listener, Preferences.Listener { ) : CommandRunner.Listener, Preferences.Listener {
private val active: HashMap<Habit, NotificationData> = HashMap()
/**
* A mapping from habits to active notifications, automatically persisting on removal.
*/
private val active = object {
private val m: HashMap<Habit, NotificationData> =
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) { fun cancel(habit: Habit) {
val notificationId = getNotificationId(habit) val notificationId = getNotificationId(habit)
systemTray.removeNotification(notificationId) systemTray.removeNotification(notificationId)
@ -64,8 +87,7 @@ class NotificationTray @Inject constructor(
fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) { fun show(habit: Habit, timestamp: Timestamp, reminderTime: Long) {
val data = NotificationData(timestamp, reminderTime) val data = NotificationData(timestamp, reminderTime)
active[habit] = data taskRunner.execute(ShowNotificationTask(habit, data, silent = false))
taskRunner.execute(ShowNotificationTask(habit, data))
} }
fun startListening() { fun startListening() {
@ -83,9 +105,9 @@ class NotificationTray @Inject constructor(
return (id % Int.MAX_VALUE).toInt() return (id % Int.MAX_VALUE).toInt()
} }
private fun reshowAll() { fun reshowAll() {
for ((habit, data) in active.entries) { for ((habit, data) in active.entries) {
taskRunner.execute(ShowNotificationTask(habit, data)) taskRunner.execute(ShowNotificationTask(habit, data, silent = true))
} }
} }
@ -101,18 +123,26 @@ class NotificationTray @Inject constructor(
habit: Habit, habit: Habit,
notificationId: Int, notificationId: Int,
timestamp: Timestamp, timestamp: Timestamp,
reminderTime: Long reminderTime: Long,
silent: Boolean = false
) )
fun log(msg: String) fun log(msg: String)
} }
internal class NotificationData(val timestamp: Timestamp, val reminderTime: Long) @Serializable
private inner class ShowNotificationTask(private val habit: Habit, data: NotificationData) : 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 { Task {
var isCompleted = false var isCompleted = false
private val timestamp: Timestamp = data.timestamp
private val reminderTime: Long = data.reminderTime
override fun doInBackground() { override fun doInBackground() {
isCompleted = habit.isCompletedToday() isCompleted = habit.isCompletedToday()
@ -128,6 +158,7 @@ class NotificationTray @Inject constructor(
habit.id habit.id
) )
) )
active.remove(habit)
return return
} }
if (!habit.hasReminder()) { if (!habit.hasReminder()) {
@ -138,6 +169,7 @@ class NotificationTray @Inject constructor(
habit.id habit.id
) )
) )
active.remove(habit)
return return
} }
if (habit.isArchived) { if (habit.isArchived) {
@ -148,6 +180,7 @@ class NotificationTray @Inject constructor(
habit.id habit.id
) )
) )
active.remove(habit)
return return
} }
if (!shouldShowReminderToday()) { if (!shouldShowReminderToday()) {
@ -158,21 +191,33 @@ class NotificationTray @Inject constructor(
habit.id habit.id
) )
) )
active.remove(habit)
return return
} }
systemTray.showNotification( systemTray.showNotification(
habit, habit,
getNotificationId(habit), getNotificationId(habit),
timestamp, data.timestamp,
reminderTime 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 { private fun shouldShowReminderToday(): Boolean {
if (!habit.hasReminder()) return false if (!habit.hasReminder()) return false
val reminder = habit.reminder val reminder = habit.reminder
val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray() val reminderDays = Objects.requireNonNull(reminder)!!.days.toArray()
val weekday = timestamp.weekday val weekday = data.timestamp.weekday
return reminderDays[weekday] return reminderDays[weekday]
} }
} }

@ -21,8 +21,11 @@ package org.isoron.uhabits.core.preferences
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.core.BaseUnitTest 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.HabitList
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.models.Timestamp.Companion.ZERO 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.isoron.uhabits.core.ui.ThemeSwitcher
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -162,6 +165,31 @@ class PreferencesTest : BaseUnitTest() {
assertFalse(prefs.showCompleted) 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<Habit, NotificationTray.NotificationData>()
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 @Test
@Throws(Exception::class) @Throws(Exception::class)
fun testMidnightDelay() { fun testMidnightDelay() {

@ -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<Habit, NotificationTray.NotificationData> =
HashMap()
override fun setActiveNotifications(activeNotifications: Map<Habit, NotificationTray.NotificationData>) {
this.activeNotifications = HashMap(activeNotifications)
}
override fun getActiveNotifications(habitList: HabitList): HashMap<Habit, NotificationTray.NotificationData> {
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)
}
}
Loading…
Cancel
Save