-
Notifications
You must be signed in to change notification settings - Fork 936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Restore notifications when app starts #1509
base: dev
Are you sure you want to change the base?
Restore notifications when app starts #1509
Conversation
The action is already performed via HabitsApplication.onCreate. It is sufficient that there is a boot receiver.
- Persist the map of active notifications on every change and reload it on application startup. - Reshow all active notifications on application startup using the original reminder due time.
So that notifications are also restored after app upgrade
It is not necessary to keep this field as in the map of active notifications (and its persisted form) this field is always `true`.
Note that I changed the implementation so that notifications are first added to the active map when the This also means that we actually don't have to persist the |
Thanks a lot! Any way to have automated tests for this? |
I looked into the existing unit tests but didn't find anything related, e.g. some stubbing/test setup that would allow to test whether persistence works correctly. Regarding instrumented tests, I am not sure how straight-forward it is to test the relevant situations (reboot, force-stop, update). What could be useful is to test whether after starting the app, previously serialized active notifications are shown (however, one has to make sure that they actually should be shown, there are some conditions). At best the serialized notifications are created through the UI as well (schedule habit reminder so that it becomes due). But I am not enough into instrumented tests to say how easy this is. |
I have now also tested that a notification from the previous day gets replaced by the new one from the current day when it is due (in the slightly updated version without persisting |
I now also pushed the small update with removing the unnecessary I think what should be fixed is that reminders becoming due while the device is off are not eventually shown when the device or even the app is started. This exists, as noted, already in the current release version 2.1.0. If you agree that this should be fixed, I would take a look. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @felixwiemuth, thank you for the pull request. I tried it out, and it seems to work well. Please see some comments below.
Regarding testing, I suggest not testing any uhabits-android
classes at this time, but I think it's important to write tests for all changes in uhabits-core
. For example, we could:
- Store notifications to preferences then immediately retrieve them and check that the retrieved version equals the original one.
- Create a NotificationTray with a mock Preferences, and ensure that the correct calls to
preferences.setActiveNotifications
are made. Also ensure thatset/remove/get
operations behave as expected.
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. | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you clarify why do we still need this block? We would still receive ACTION_BOOT_COMPLETED
, even if we remove the block.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The block is not needed (and I can see the intent is also logged above); What I deemed important is that somewhere it is noted that it is important that the ACTION_BOOT_COMPLETED
intent is received.
Actually, as for both ACTION_BOOT_COMPLETED
and MY_PACKAGE_REPLACED
the only thing we need is that the application is started (and all the code from ReminderReceiver
is not needed), I would suggest that both should be received by a common dummy receiver (as currently with UpdateReceiver
), which we could call StartAppReceiver
or similar. Then it is clear that these intents are solely received to let the app start.
class UpdateReceiver : BroadcastReceiver() { | ||
|
||
override fun onReceive(context: Context, intent: Intent) { | ||
// Dummy receiver, relevant code is executed through HabitsApplication. | ||
Log.d("UpdateReceiver", "Update receiver called.") | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need a new (empty) receiver? Could we add MY_PACKAGE_REPLACED
to the list of intent filters of ReminderReceiver instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comment above.
for ((habit, data) in active.entries) { | ||
taskRunner.execute(ShowNotificationTask(habit, data)) | ||
taskRunner.execute(ShowNotificationTask(habit, data, true)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For boolean arguments, it's best to add the argument name (shown = true
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
private inner class ShowNotificationTask( | ||
private val habit: Habit, | ||
private val data: NotificationData, | ||
private val shown: Boolean |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
silent
is a bit more clear to me. Changing it would also make it consistent with the other class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, done.
return if (serialized == "") { | ||
HashMap() | ||
} else { | ||
val activeById = Json.decodeFromString(MapSerializer(Long.serializer(), NotificationTray.NotificationData.serializer()), serialized) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if the decoding fails? Does the app fail to boot? I think this could happen if: (i) user receives some notifications; (ii) we update NotificationData
with some new/renamed fields; (iii) user installs updates and restarts the app. The new version of the app would not be able to read serialized data from the old version.
I suggest adding a try/catch block here, and returning HashMap()
if the operation fails.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes currently there would be an uncaught exception if decoding fails, and the app would crash on startup. That is of course bad, even though unlikely. Returning an empty map in case it cannot be deserialized is fine, as (as far as I can see) no other functionality depends on all active notifications being available in the map.
What we could do is show a notification that notifications could not be restored and the user should make sure to look at the habits manually, but regarding that it is a corner case it's probably not worth it.
Btw., decoding will not fail if fields not present in the encoded string have default values in the Kotlin class.
This would be a nice feature, but it could also be a separate PR. |
In case serialized notifications cannot be deserialized, continue with an empty map of active notifications
I agree that this should be a separate PR (or issue for now). I have also a few other related things where I'd open a new issue for. |
I'm currently looking into adding a test to
I started writing a test with a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have now added the described tests in their current state. They check most of what we discussed. See the comments for further info.
notificationTray.show(habit, timestamp, reminderTime) | ||
|
||
// Verify that the active notifications include exactly the one shown reminder | ||
// TODO are we guaranteed that task has executed? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As a ShowNotificationTask
is executed by a task runner, I am not sure whether this might be asynchronous and we should for example add a delay here to increase the likelihood that it has executed? Or are tasks (at least for these tests) run in the same thread, which would make tests safer?
notificationTray.cancel(habit) | ||
assertThat(preferences.getActiveNotifications(habitList).size, equalTo(0)) | ||
|
||
// TODO test cases where reminders should be removed (e.g. reshowAll) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we could add tests whether the remaining methods which touch the active notifications map remove entries in the correct situations. It is a little work to setup all the special cases, I can't promise right now when I'll be able to do that.
assertThat(a.keys, equalTo(b.keys)) | ||
a.forEach { e -> assertThat(b[e.key], equalTo(e.value)) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a manual workaround for the missing matcher/library function I was looking for.
@iSoron if the state of the PR seems ok I can try to rebase it, let me know. AFAICT from reading the comments, all the concerns were addressed. |
Overview
This pull request makes notifications from habit reminders survive reboots, app updates and force-closing by recreating them on app startup. After reboot and app update, notifications reappear without having to start the app manually, after a force-close, they reappear after starting the app manually. In all of these cases, notifications were previously gone and not recreated.
When notifications that had been shown before are recreated, they are shown silently, to not give the impression that they just became due. As before, notifications display the original reminder time - that is, when a notification is recreated, it doesn't display as "now", but shows the relative time since the reminder was due.
This implements #172.
Implementation
Technically, this is implemented in the following way:
NotificationTray
class is instantiated.NotificationData
objects (which now also includes a flag whether the notification has been shown already) toSharedPreferences
, whenever this map is changed.HabitsApplication
callsNotificationTray.reshowAll()
which results in all active notifications being reshown. This also happens after reboot (because of receiving theBOOT_COMPLETED
intent) and after app upgrade (because of receiving theMY_PACKAGE_REPLACED
intent).ReminderReceiver
initiated a rescheduling directly; this was and is still not necessary, as it was and is initiated inHabitsApplication
.Testing
Manually tested successfully the following on a Pixel 4a (Android 13) to check whether it works as intended, including some previous behaviour: