Skip to content

feat(alerts): notify about wishlist sales and free games - #4

Merged
JuggyBob merged 1 commit into
mainfrom
feature/deal-alerts
Oct 20, 2026
Merged

JuggyBob merged 1 commit into
mainfrom
feature/deal-alerts

Conversation

@JuggyBob

Copy link
Copy Markdown
Owner

Adds deal alerts: a notification when a game you saved goes on sale, and when a game becomes
free to keep — with the name, the new price and the discount — delivered with the app closed.

How the background check works

The check runs on two mechanisms, because one is never enough:

Punctual? Survives a reboot?
AlarmManager alarm (setAndAllowWhileIdle) yes no
WorkManager periodic worker no — the system defers it, Doze pushes it out, battery managers delay it yes

Both end up running the same idempotent check, so overlapping triggers cannot double-notify. The
exact-alarm permission is asked for rather than assumed: if it is refused, the scheduler falls back
to an inexact alarm, which still works inside Doze — a later notification beats none.

What gets announced

Only changes, because the check runs on a timer and sees the same deals over and over:

  • a wishlisted game that has started being discounted, and
  • a game that has become free.

A game that is still discounted next hour is not news. A game missing from the feed leaves its
record alone — the feed holds only the top few dozen discounts per store, so dropping out of it says
nothing either way. The record is cleared only when the game is seen and is not discounted, which
is the one observation that proves the sale ended.

The first check announces nothing. Without a previous observation, nothing can be shown to have
become anything, and announcing the current state would mean one notification per free game the
moment the feature is switched on.

Bugs found and fixed along the way

All five were found by running this on a device, not by reading the code:

  1. The check never ran at all. WishlistRepository.loadOnce() awaited a DataStore flow with
    collect, which never returns — the flow stays open for the life of the process. The worker
    started, blocked on the wishlist load, and hung with no error, no crash report and no
    notification.
  2. Two scheduling policies, both wrong. REPLACE cancelled the in-flight check, and because
    the two triggers can fire together that became a cancellation storm in which no check ever
    completed. KEEP fixed that but let a work stuck in a queued state block every later request
    under the same name, so the checks stopped silently.
  3. The release build crashed on launch, and the debug build did not. WorkManager keeps its
    schedule in a Room database, and Room instantiates the generated implementation reflectively — a
    call R8 cannot see, so R8 removed it. Every debug run passed; the crash existed only in the build
    that would have been shipped.
  4. A notification's URI arrived with data == null. The intent that was built was correct and
    the intent that arrived had no data, so tapping a notification opened the app and went nowhere.
    The identity now travels in intent extras, which are copied verbatim.
  5. A free game was announced twice — as "is free" and as "100% off".

Release signing

The release workflow now signs with the keystore from a repository secret, verifies the signature
before publishing
, and refuses to publish at all if the secret is missing. A release signed with
the wrong key installs once and then refuses to update, which is a worse outcome than no release.

Verification

  • 355 unit tests across 27 suites, 0 failures.
  • Lint clean; debug and release APKs build from clean.
  • The release APK is signed with the release key (CN=Ivan Bizimoski, APK Signature Scheme v2),
    reports versionName=1.1.0-release / versionCode=2, and was installed and launched on a
    device with no crash.
  • The notification path was verified end to end with the app closed: the process was killed, the
    system started the app itself when the alarm fired, and the notifications were posted. Tapping one
    opens that game's page, showing the same price the notification did.

The app now tells you when a game you saved goes on sale, and when a game
becomes free to keep - with the name, the new price and the discount - and it
does so with the app closed.

The background check runs on two mechanisms, because one is never enough.
WorkManager's periodic worker is the officially supported way to do background
work and it survives reboots, but a periodic worker is a request rather than a
promise: the system defers it, Doze pushes it out, and battery managers delay
it further. An alarm is punctual and setAndAllowWhileIdle is delivered inside a
Doze window, but alarms do not survive a reboot on their own. Both end up
running the same idempotent check, so overlapping triggers cannot double-notify.

Only changes are announced. A wishlisted game that starts being discounted is
news; one that is still discounted next hour is not. A game missing from the
feed leaves its record alone, because the feed holds only the top few dozen
discounts per store and dropping out of it says nothing either way. The record
is only cleared when the game is seen and is not discounted, which is the one
observation that proves the sale ended.

Five bugs are fixed along the way, all of which were found by running this on
a device rather than by reading the code:

  * The check never ran at all. WishlistRepository.loadOnce() awaited a
    DataStore flow with collect, which never returns, so the worker hung with
    no error, no crash report and no notification.
  * Two scheduling policies, both wrong. REPLACE cancelled the in-flight check
    and, because the two triggers can fire together, that became a cancellation
    storm in which no check ever completed. KEEP fixed that but let a work
    stuck in a queued state block every later request under the same name.
  * The release build crashed on launch and the debug build did not. WorkManager
    keeps its schedule in a Room database, and Room instantiates the generated
    implementation reflectively - a call R8 cannot see, so R8 removed it. Every
    debug run passed; the crash existed only in the build that would have been
    shipped.
  * A notification's URI arrived with data == null, so tapping one opened the
    app and went nowhere. The identity now travels in intent extras.
  * A free game was announced twice, as "is free" and as "100% off".

Also in this release:

  * The release workflow signs with the keystore from a repository secret and
    verifies the signature before publishing, and refuses to publish at all if
    the secret is missing. A release signed with the wrong key installs once
    and then refuses to update.
  * versionCode 2, versionName 1.1.0-release.
@JuggyBob
JuggyBob merged commit 839b343 into main Oct 20, 2026
3 checks passed
@JuggyBob
JuggyBob deleted the feature/deal-alerts branch October 20, 2026 19:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant