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>
</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"
android:exported="true"
android:permission="false">

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

@ -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

@ -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(
R.drawable.ic_action_check,
@ -131,6 +134,7 @@ class AndroidNotificationTray
.setSound(null)
.setWhen(reminderTime)
.setShowWhen(true)
.setSilent(silent)
.setOngoing(preferences.shouldMakeNotificationsSticky())
if (habit.isNumerical) {

@ -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,

@ -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) {

@ -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 {
kotlin("multiplatform")
kotlin("plugin.serialization") version "1.7.10"
id("org.jlleitschuh.gradle.ktlint")
}
@ -31,6 +32,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")
}
}

@ -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<Timestamp> {
constructor(cal: GregorianCalendar) : this(cal.timeInMillis)

@ -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<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) {
listeners.remove(listener)
}

@ -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<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) {
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))
}
}
@ -101,18 +123,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()
@ -128,6 +158,7 @@ class NotificationTray @Inject constructor(
habit.id
)
)
active.remove(habit)
return
}
if (!habit.hasReminder()) {
@ -138,6 +169,7 @@ class NotificationTray @Inject constructor(
habit.id
)
)
active.remove(habit)
return
}
if (habit.isArchived) {
@ -148,6 +180,7 @@ class NotificationTray @Inject constructor(
habit.id
)
)
active.remove(habit)
return
}
if (!shouldShowReminderToday()) {
@ -158,21 +191,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]
}
}

@ -21,8 +21,11 @@ package org.isoron.uhabits.core.preferences
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<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
@Throws(Exception::class)
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