diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8c2e46e..7039255 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -73,25 +73,56 @@ jobs:
# Never ship a red build, however tempting the tag feels.
run: ./gradlew testDebugUnitTest lintDebug --stacktrace
- # To sign releases, add a keystore as a repository secret and uncomment:
+ # ------------------------------------------------------------------ signing
#
- # - name: Decode signing key
- # run: echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
+ # The keystore lives in a repository secret, never in the repository. `assembleRelease`
+ # without one falls back to the shared debug key, which produces an APK that installs but
+ # cannot be upgraded over an existing install — so a signed build is not a nicety here, it is
+ # the difference between "updatable" and "you have to uninstall first".
#
- # - name: Sign release APK
- # run: |
- # $ANDROID_HOME/build-tools/*/apksigner sign \
- # --ks release.jks \
- # --ks-pass env:KS_PASS \
- # --key-pass env:KEY_PASS \
- # --out app-release-signed.apk \
- # app/build/outputs/apk/release/app-release-unsigned.apk
+ # The values are handed to the build as environment variables rather than written into
+ # `keystore.properties`, because `app/build.gradle.kts` already reads `STORE_FILE`,
+ # `STORE_PASSWORD`, `KEY_ALIAS` and `KEY_PASSWORD` from the environment as a fallback. That
+ # keeps the secret out of the filesystem entirely, and keeps the signing configuration in one
+ # place instead of a second, divergent one in this workflow.
+ - name: Restore the signing key
+ if: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' }}
+ env:
+ KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
+ STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
+ run: |
+ echo "$KEYSTORE_BASE64" | base64 -d > release-key.p12
+ # Written to $GITHUB_ENV, not echoed: the value goes into the file, not the log.
+ echo "STORE_FILE=$GITHUB_WORKSPACE/release-key.p12" >> "$GITHUB_ENV"
+ echo "KEY_ALIAS=key0" >> "$GITHUB_ENV"
+ echo "STORE_PASSWORD=$STORE_PASSWORD" >> "$GITHUB_ENV"
+ echo "KEY_PASSWORD=$STORE_PASSWORD" >> "$GITHUB_ENV"
+ echo "the signing key has been restored"
+
+ - name: Refuse to publish without a signing key
+ # A release signed with the wrong key is worse than no release: it installs once and then
+ # refuses to update, and the user has to uninstall and lose their settings. Failing here
+ # is the point.
+ if: ${{ secrets.RELEASE_KEYSTORE_BASE64 == '' }}
+ run: |
+ echo "::error::RELEASE_KEYSTORE_BASE64 is not set, so the release would be signed with the debug key."
+ echo "::error::Add the keystore as a repository secret and re-run the release."
+ exit 1
- name: Build release APK
run: ./gradlew assembleRelease --stacktrace
+ - name: Verify the signature
+ # A release that is not signed with the release key is worse than no release: it installs
+ # once and then refuses to update. Checked here, before anything is published.
+ if: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' }}
+ run: |
+ APKSIGNER=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | tail -1)
+ "$APKSIGNER" verify --print-certs app/build/outputs/apk/release/*.apk
+ echo "signature verified"
+
- name: Rename the APK with its version
- # `1.0.0.apk` in the downloads list beats `app-release.apk`.
+ # `1.1.0.apk` in the downloads list beats `app-release.apk`.
run: |
mkdir -p dist
cp app/build/outputs/apk/release/*.apk "dist/discount-hunter-${{ steps.version.outputs.name }}.apk"
@@ -111,11 +142,12 @@ jobs:
### Installing
- The APK is unsigned, so Android will ask you to allow installation from your file
- manager the first time. To sign it yourself, see the commented signing step in
- `.github/workflows/release.yml`.
+ The APK is signed with the release key, so it installs over any earlier version
+ without an uninstall. If your device warns about an unknown source, allow your file
+ manager to install apps — that is the one prompt Android asks for.
### Verifying the build
`ubuntu-latest`, JDK 17, `./gradlew testDebugUnitTest lintDebug assembleRelease`.
- Every APK here was produced by that exact command.
+ Every APK here was produced by that exact command, and its signature is verified
+ before it is published.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db26419..572647f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,62 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Nothing yet. Open an issue or PR to propose something.
+## [1.1.0] - 2026-10-20
+
+Deal alerts: 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**.
+
+### Added
+
+- **Background deal alerts.** A wishlisted game that starts being discounted is announced, with its
+ name, its new price and the discount rate. Free games are announced too, whether or not they are
+ saved. Two notification channels, so "Sales on your wishlist" and "Free games" can be tuned
+ separately.
+- **A Deal alerts switch in Settings**, in its own card, explaining what is checked and how often.
+ When notifications are blocked at the system level, the screen says so and links straight to the
+ system notification settings rather than leaving the switch quietly doing nothing.
+- **Tapping a notification opens that game**, showing the same price the notification did.
+- **Prices in notifications are converted** the same way the app converts them, and marked `~` so a
+ converted figure is never mistaken for a quoted one.
+
+### Fixed
+
+- **The background 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. It now reads the current value with `first()`.
+- **Two scheduling policies, both wrong.** `REPLACE` cancelled the in-flight check, and because the
+ alarm and the periodic worker 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. The request is now simply enqueued;
+ overlapping runs are harmless, because a notification posted under the same tag and id replaces
+ itself rather than adding to the shade.
+- **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. Fixed with two keep rules, and the release APK is now installed and
+ launched on a device before the work is called done.
+- **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 and cannot be lost that way.
+- **A notification's deep link showed the wrong price.** Without a title, the detail page looks a
+ GOG game up with a catalogue search, and that returns unrelated products for anything but a
+ distinctive name. The title now travels with the link, and the page shows the same price the
+ notification did.
+- **A free game was announced twice** — as "is free" and as "100% off". A free deal is now skipped
+ by the sale path, and its record cleared so that a later paid discount is still announced.
+
+### Changed
+
+- The check runs on **two** mechanisms rather than one: an `AlarmManager` alarm, which is punctual
+ and Doze-aware, and a WorkManager periodic job, which is guaranteed and survives a reboot. 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.
+- The background check fetches a larger feed than the app shows, so a wishlisted game at a shallow
+ discount is not missed below the cut-off.
+
## [1.0.0] - 2026-10-11
The first public release: a native Android port of the Discount Hunter desktop app, plus the fixes
@@ -134,5 +190,7 @@ failure mode behind every one.
reachable through deep links rather than being scraped, because most of them block programmatic
access.
-[Unreleased]: https://github.com/JuggyBob/DiscountHunterAndroid/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/JuggyBob/DiscountHunterAndroid/releases/tag/v1.0.0
+
+[Unreleased]: https://github.com/JuggyBob/DiscountHunterAndroid/compare/v1.1.0...HEAD
+[1.1.0]: https://github.com/JuggyBob/DiscountHunterAndroid/releases/tag/v1.1.0
diff --git a/DEV_NOTES.md b/DEV_NOTES.md
index 41aaae4..f31d7aa 100644
--- a/DEV_NOTES.md
+++ b/DEV_NOTES.md
@@ -685,6 +685,109 @@ and the animation body is wrapped so a cancellation cannot escape as a crash.
Verified with ~150 rapid toggles including 60 at 25 ms intervals on a single row, with and without
scrolling: no crash, and the reorder animation is visibly back.
+### 4.39 The background check never ran at all
+
+The feature was built, the schedule armed, the alarm registered with the system — and the check
+never completed. The worker started, produced no log line, no notification, and no error.
+
+`WishlistRepository.loadOnce()` looked like this:
+
+```kotlin
+suspend fun loadOnce() {
+ val snapshot = settings.settings.map { it.wishlist }.distinctUntilChanged()
+ var first: List? = null
+ snapshot.collect { value -> first = value.map { it.toEntry() } }
+ rebuild(first.orEmpty())
+}
+```
+
+`collect` on a DataStore flow **never returns** — the flow stays open for as long as the process
+lives. So a caller that awaited `loadOnce()` waited forever. The background worker did exactly
+that: it started, blocked on the wishlist load, and hung. There was no exception and no crash
+report, because nothing was wrong: the code was waiting for something that was never going to
+happen.
+
+It now reads the current value with `first()`. The function was only ever *used* by tests, where
+the flow completed and the bug could not show.
+
+### 4.40 Two scheduling policies, both wrong
+
+The one-shot check is enqueued under a unique name so that overlapping triggers cannot pile up.
+Both obvious policies are wrong, and both were wrong in practice:
+
+ * **`REPLACE`** cancels whatever is already running. The alarm and the periodic worker can fire
+ within seconds of each other, and that became a cancellation storm: each new request killed the
+ one that had just started, so no check ever completed. The log showed
+ `Work [...] was cancelled` immediately followed by `Starting work for DealAlertWorker`.
+ * **`KEEP`** never cancels, but a work that is stuck in a queued state — waiting for a network
+ that is not coming — blocks every later request under the same name. The checks then stop
+ happening, silently.
+
+The request is now simply enqueued, with no unique name. Overlapping runs are harmless: the
+notification for a game is posted under that game's own tag and id, so a second post *replaces* the
+first rather than adding to the shade. The user sees one notification either way. A process-wide
+mutex inside the worker serialises the runs so they do not fetch the same feed twice.
+
+### 4.41 A notification's URI arrived with `data == null`
+
+Tapping a notification opened the app and went nowhere.
+
+The intent was built with the game's URI, and the log confirmed the intent that was *built* was
+correct:
+
+```
+Alerts: tap target for 'Dance of Cards Soundtrack' is
+ discounthunter://gog/dance_of_cards_soundtrack?title=Dance%20of%20Cards%20Soundtrack
+```
+
+But the intent that *arrived* had no data at all:
+
+```
+Main: onNewIntent action=android.intent.action.VIEW extras=[] data=null
+```
+
+Making the intent explicit, and dropping `FLAG_ACTIVITY_CLEAR_TOP`, changed nothing — the data was
+still null. The fix was to stop using a URI: the identity now travels in **intent extras**, which
+are copied verbatim and cannot be dropped the way a URI data can be.
+
+The extras also carry the game's **title**, and that matters for a different reason. Without a
+title the detail page has to look the game up by name, and for GOG that means a catalogue *search* —
+which returns unrelated products for anything but a distinctive name, so the page showed the wrong
+offer, or none. With the title the page resolves correctly and the price on it matches the
+notification exactly: `$0.25 (was $4.99, -95%)` in both places.
+
+### 4.42 A free game was announced twice
+
+A wishlisted game that is free was announced by the free-game path *and* as a sale, so it produced
+two notifications: "Agent A is free" and "Agent A is 100% off". The second is the less useful of
+the two, and the duplicate is the kind of thing that makes people turn a feature off.
+
+A free deal is now skipped by the sale path, and its record is cleared so that if the game later
+becomes a *paid* discount, that is announced as a sale.
+### 4.43 The release build crashed on launch, and the debug build did not
+
+The most expensive bug in this round, and it was invisible until the *release* APK was installed.
+
+```
+java.lang.RuntimeException: Unable to get provider androidx.startup.InitializationProvider
+Caused by: java.lang.NoSuchMethodException: androidx.work.impl.WorkDatabase_Impl. []
+```
+
+WorkManager keeps its schedule in a Room database, and Room instantiates the generated
+implementation reflectively — `WorkDatabase_Impl`, by name, with a no-argument constructor. R8
+cannot see that call, so it removed the class.
+
+The debug build was fine, because R8 only runs for release. Every debug test, every emulator run
+and every unit test passed; the crash only ever appeared in the one build that would have been
+shipped. That is the shape of bug that reaches users and never reaches the person developing it,
+and the only defence is to install the release APK before believing anything.
+
+The fix is two keep rules in `proguard-rules.pro`:
+
+```
+-keep class * extends androidx.room.RoomDatabase { (); }
+-keep class androidx.work.impl.** { *; }
+```
---
## 5. Design decisions worth explaining
@@ -699,6 +802,34 @@ results keep the stores' relevance order — among those, the store's own rankin
answer available. The same rule is deliberately *not* applied to the feed (§4.30): search caps at
25 rows and the user is looking at a handful, so the placement animations are bounded, whereas the
feed reorders ninety grid rows on every tap.
+**The background check runs on two mechanisms, because one is never enough.** The single most
+common reason an app's notifications "do not always work" is that it relies on one mechanism, and
+that mechanism has a hole. `WorkManager` 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 batches
+and defers it, Doze pushes it out, and several manufacturers' 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 and an app that is force-stopped loses them. So both are used: the
+alarm is the punctual path and the periodic worker is the guaranteed backstop, and both end up
+running the same idempotent check.
+
+The exact-alarm permission is asked for, not assumed. `canScheduleExactAlarms()` is checked and the
+scheduler falls back to `setAndAllowWhileIdle`, which needs no permission and still works inside
+Doze. A less punctual notification is much better than none.
+
+**A notification carries a game's identity in extras, not in a URI.** A URI in a notification's
+intent was measured arriving with `data == null` — the intent the activity received was not the one
+that had been built — so the notification opened the app without going anywhere. Extras are copied
+verbatim and cannot be lost that way. They also carry the title, which the detail page needs: for
+GOG, looking a game up by name means a catalogue search, and that returns unrelated products for
+anything but a distinctive title, so the page showed the wrong offer or none.
+
+**Only changes are announced, and absence is not evidence.** A check runs on a timer, so the same
+deals are seen over and over. What is worth telling someone about is a *change*: 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. When a wishlisted game is missing from the feed its record is left alone —
+the feed holds only the top few dozen discounts per store, so dropping out of it says nothing about
+whether the game is still discounted. The record is only cleared when the game is *seen* and is not
+discounted, which is the one observation that proves the sale ended.
**The app keeps its own log.** `AppLog` is a bounded ring buffer of the last few hundred state
transitions and caught failures, written to a shareable file from Settings. `adb logcat` is not an
answer for a user without a computer, and by the time someone thinks to report a crash the buffer
diff --git a/README.md b/README.md
index 775c9f2..f23e0d2 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ games to watch their prices.
| 🔎 **Cross-store search** | Search all three stores at once and get one deduplicated result per game, not one per store |
| 💶 **An honest price** | Prices are quoted in the currency the store actually used, and genuinely converted when they differ — never relabelled |
| ⭐ **Wishlist** | Save any game from the feed, search results or a detail page. Saved games rise to the top everywhere they appear |
+| 🔔 **Deal alerts** | A notification when a saved game goes on sale, and when a game becomes free — with the name, the new price and the discount. Checked in the background, including when the app is closed |
| 🖼️ **Rich detail pages** | Hero art, screenshots, trailers, system requirements, genres, "Where to buy" comparing every store that has the game |
| 📈 **Price history** | A chart of prices **this device** recorded, with deep links to external databases |
| 🎨 **Material You** | Follows your system light/dark theme, edge-to-edge, with hand-tuned motion throughout |
@@ -34,6 +35,12 @@ games to watch their prices.
+## What is new in 1.1.0
+
+**Deal alerts.** 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**. See [CHANGELOG.md](CHANGELOG.md) for the full list, including the five bugs this release
+fixes along the way.
## Download
@@ -51,11 +58,12 @@ raw file path, which a phone browser will either refuse or save with the wrong n
Every release contains:
-- **`discount-hunter-.apk`** — the optimised release build
+- **`discount-hunter-.apk`** — the optimised release build, signed with the release key
- **Source code (zip)** and **(tar.gz)** — generated by GitHub from the tagged commit
-The APK is **unsigned**, so Android will not install it until you allow your file manager to. The
-first time:
+The APK is signed with the project's release key, so it installs **over** any earlier version
+without an uninstall. If your device warns about an unknown source, allow your file manager to
+install apps — that is the one prompt Android asks for:
1. Tap the downloaded `.apk`.
2. Android says it is from an unknown source → **Settings** → allow your file manager to install apps.
@@ -126,6 +134,7 @@ app/src/main/java/com/discounthunter/android/
│ ├── epic/ # Epic free-games feed (GraphQL)
│ ├── cache/ # Region-scoped snapshots, payload cache
│ └── repo/ # Repositories merging the stores together
+├── alerts/ # Background deal alerts: detection, scheduling, notification
├── diagnostics/ # In-app log + crash capture used by the export button
├── di/ # AppContainer — the hand-rolled dependency graph
└── ui/ # Compose screens, one package per screen, plus shared components
@@ -133,7 +142,7 @@ app/src/main/java/com/discounthunter/android/
There is no dependency-injection framework and no database — DataStore for preferences, files for
the price-history snapshots, and a small hand-wired container for object lifetimes. The whole app is
-~70 Kotlin source files.
+~80 Kotlin source files.
Some decisions worth knowing about, all explained at length in
[`docs/ENGINEERING.md`](docs/ENGINEERING.md) and [`DEV_NOTES.md`](DEV_NOTES.md):
@@ -145,11 +154,15 @@ Some decisions worth knowing about, all explained at length in
with `~`, so a converted price is never mistaken for a quoted one.
- **Media belongs to a verified identity.** A game's screenshots and trailers are only ever shown
when the store positively confirmed the id, so one game can never display another's media.
+- **The background check runs on two mechanisms.** An `AlarmManager` alarm is punctual and
+ Doze-aware; a WorkManager periodic job is guaranteed and survives a reboot. Both end up running
+ the same idempotent check, so overlapping triggers cannot double-notify. Only *changes* are
+ announced — a game that is still discounted tomorrow is not announced again.
## Testing
```bash
-./gradlew testDebugUnitTest # 334 unit tests across 26 suites
+./gradlew testDebugUnitTest # 355 unit tests across 27 suites
```
The suite covers the things that are easy to get quietly wrong: cross-store identity and media
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9ca3be2..456293f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -35,8 +35,8 @@ android {
applicationId = "com.discounthunter.android"
minSdk = 24
targetSdk = 36
- versionCode = 1
- versionName = "1.0.0-release"
+ versionCode = 2
+ versionName = "1.1.0-release"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -137,6 +137,9 @@ dependencies {
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
+ // The background check. WorkManager is the officially supported way to do work that must
+ // survive the app being closed, and it reschedules itself after a reboot.
+ implementation(libs.androidx.work.runtime.ktx)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index cdb95b3..1445662 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -7,3 +7,21 @@
# Keep line numbers so release crash reports stay readable, but hide the original file name.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
+
+# --- WorkManager -------------------------------------------------------------
+#
+# WorkManager keeps its schedule in a Room database, and Room instantiates the generated
+# implementation reflectively: `WorkDatabase_Impl` is created by name, with a no-argument
+# constructor, by a call R8 cannot see.
+#
+# Without this, R8 removes the class and the *release* build dies on launch, before any screen
+# is drawn:
+#
+# java.lang.RuntimeException: Unable to get provider androidx.startup.InitializationProvider
+# Caused by: java.lang.NoSuchMethodException: androidx.work.impl.WorkDatabase_Impl. []
+#
+# The debug build is unaffected, because R8 only runs for release - which is exactly how a crash
+# like this reaches users and never reaches the person developing it.
+-keep class * extends androidx.room.RoomDatabase { (); }
+-keep class androidx.work.impl.** { *; }
+-dontwarn androidx.work.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f953c02..a76e6bb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/discounthunter/android/MainActivity.kt b/app/src/main/java/com/discounthunter/android/MainActivity.kt
index 7ff2254..7259336 100644
--- a/app/src/main/java/com/discounthunter/android/MainActivity.kt
+++ b/app/src/main/java/com/discounthunter/android/MainActivity.kt
@@ -63,6 +63,7 @@ import com.discounthunter.android.core.model.SearchResult
import com.discounthunter.android.core.model.StoreId
import com.discounthunter.android.core.model.StoreOffer
import com.discounthunter.android.data.settings.ThemeMode
+import com.discounthunter.android.diagnostics.AppLog
import com.discounthunter.android.data.store.LinkResult
import com.discounthunter.android.data.store.StoreLinks
import com.discounthunter.android.di.AppContainer
@@ -104,13 +105,14 @@ class MainActivity : ComponentActivity() {
* therefore dropped it on the floor, and the link appeared to do nothing. Writing the new intent
* back means the same `LaunchedEffect` that handles a cold start handles a warm one.
*/
- private val pendingDeepLink = MutableStateFlow(null)
+ private val pendingDeepLink = MutableStateFlow(null)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
- pendingDeepLink.value = intent?.data
+ AppLog.i("Main: onCreate action=${intent?.action} extras=${intent?.extras?.keySet()} data=${intent?.data}")
+ pendingDeepLink.value = referenceFromIntent(intent)
val container = (application as DiscountHunterApplication).container
@@ -138,7 +140,9 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
// Keep getIntent() in step, so anything else reading it sees the newest link.
setIntent(intent)
- if (intent.data != null) pendingDeepLink.value = intent.data
+ AppLog.i("Main: onNewIntent action=${intent.action} extras=${intent.extras?.keySet()} data=${intent.data}")
+ val reference = referenceFromIntent(intent)
+ if (reference != null) pendingDeepLink.value = reference
}
}
@@ -181,7 +185,7 @@ private val TOP_LEVEL = listOf(
private fun DiscountHunterApp(
container: AppContainer,
storeLinks: StoreLinks,
- deepLink: StateFlow,
+ deepLink: StateFlow,
onDeepLinkHandled: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -229,7 +233,7 @@ private fun DiscountHunterApp(
private fun MainShell(
container: AppContainer,
storeLinks: StoreLinks,
- deepLink: StateFlow,
+ deepLink: StateFlow,
onDeepLinkHandled: () -> Unit,
) {
val navController = rememberNavController()
@@ -254,14 +258,11 @@ private fun MainShell(
// Keyed on the link's *value*, not on the flow. `LaunchedEffect` compares its keys with equals,
// and a `StateFlow` instance equals itself forever, so keying on the flow runs this exactly once
// — which would quietly drop every link that arrives after the first.
- val deepLinkUri by deepLink.collectAsStateWithLifecycle()
+ val deepLinkReference by deepLink.collectAsStateWithLifecycle()
- LaunchedEffect(deepLinkUri) {
- val uri = deepLinkUri ?: return@LaunchedEffect
- val reference = referenceFromUri(uri)
- if (reference != null) {
- navController.navigate(Destinations.detailRoute(reference.identity, reference.title))
- }
+ LaunchedEffect(deepLinkReference) {
+ val reference = deepLinkReference ?: return@LaunchedEffect
+ navController.navigate(Destinations.detailRoute(reference.identity, reference.title))
onDeepLinkHandled()
}
@@ -498,14 +499,41 @@ private fun referenceFromArguments(
}
/**
- * Resolves an incoming deep link to a game.
+ * Resolves an incoming intent to a game.
*
* Two shapes are understood:
- * * the stores' own web addresses, via [GameIdentity.fromUrl];
- * * the app's own scheme, `discounthunter:///` — where the store is the *host* and the
- * id is the first path segment, so `discounthunter://steam/1245620` opens the same game as the
- * Steam URL. Reading the host alone would have produced the id `"steam"` and a page for a game
- * that does not exist.
+ *
+ * * **Extras** — how a notification tap arrives. The store and the id are carried as extras
+ * rather than in a URI, because a URI in a notification's intent was measured arriving with
+ * `data == null`: the intent the activity received was not the one that had been built. Extras
+ * are copied verbatim, so they cannot be lost that way.
+ * * **A URI** — the stores' own web addresses via [GameIdentity.fromUrl], and the app's own
+ * scheme, `discounthunter:///`, where the store is the *host* and the id is the
+ * first path segment. Reading the host alone would have produced the id `"steam"` and a page
+ * for a game that does not exist.
+ *
+ * A `title` may come with either, and it matters. Without a title the detail page has to work
+ * the game's name out for itself, and for GOG that means a catalogue *search* by title — which
+ * returns unrelated products for anything but the most distinctive of names, so the page ends up
+ * with the wrong offer or none. The title is a hint, never an identity: the id still decides which
+ * game this is.
+ */
+private fun referenceFromIntent(intent: Intent?): GameReference? {
+ if (intent == null) return null
+
+ val storeName = intent.getStringExtra(Destinations.EXTRA_STORE)?.trim().orEmpty()
+ val id = intent.getStringExtra(Destinations.EXTRA_ID)?.trim().orEmpty()
+ if (storeName.isNotEmpty() && id.isNotEmpty()) {
+ val identity = GameIdentity.parse("$storeName:$id") ?: return null
+ val title = intent.getStringExtra(Destinations.EXTRA_TITLE)?.trim().orEmpty()
+ return GameReference(identity = identity, title = title)
+ }
+
+ return referenceFromUri(intent.data)
+}
+
+/**
+ * Resolves a deep link to a game.
*/
private fun referenceFromUri(uri: Uri?): GameReference? {
val data = uri?.toString() ?: return null
@@ -515,7 +543,8 @@ private fun referenceFromUri(uri: Uri?): GameReference? {
val id = uri.pathSegments.firstOrNull()?.trim().orEmpty()
if (store.isEmpty() || id.isEmpty()) return null
val identity = GameIdentity.parse("$store:$id") ?: return null
- return GameReference(identity = identity, title = "", originUrl = data)
+ val title = uri.getQueryParameter("title")?.trim().orEmpty()
+ return GameReference(identity = identity, title = title, originUrl = data)
}
val identity = GameIdentity.fromUrl(data) ?: return null
diff --git a/app/src/main/java/com/discounthunter/android/alerts/AlertModels.kt b/app/src/main/java/com/discounthunter/android/alerts/AlertModels.kt
new file mode 100644
index 0000000..bfd558b
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/AlertModels.kt
@@ -0,0 +1,99 @@
+package com.discounthunter.android.alerts
+
+import com.discounthunter.android.core.model.GameIdentity
+import com.discounthunter.android.core.model.StoreId
+
+/**
+ * A wishlisted game that has just gone on sale.
+ *
+ * [identityKey] is the wishlist entry's key, not the store's own id for the deal. It is what the
+ * announcement is recorded against, so it has to survive the game being re-listed at a different
+ * price — which is exactly when a second notification is wanted.
+ */
+data class SaleAlert(
+ val identityKey: String,
+ val title: String,
+ val store: StoreId,
+ val identity: GameIdentity?,
+ /** The price the game is on sale for right now. */
+ val price: Double,
+ /** What it cost before the discount, when the store published one. */
+ val originalPrice: Double,
+ /** The discount, as the store published it: 75.0 means 75% off. */
+ val discountPercent: Double,
+ /**
+ * The currency the store quoted the prices in.
+ *
+ * Blank means the store re-quotes for the user's region and the numbers are already in the
+ * chosen currency. GOG publishes in dollars whatever region is asked for, so its rows carry
+ * `"USD"` and the notification converts them — the same rule the app uses, so a notification
+ * and the page it opens cannot disagree.
+ */
+ val currencyCode: String = "",
+ val storeUrl: String,
+ val thumbUrl: String,
+) {
+ /** True when the store published a "was" price worth showing. */
+ val hasOriginalPrice: Boolean get() = originalPrice > price && originalPrice > 0
+}
+
+/**
+ * A game that is free to keep, and that was not free the last time we looked.
+ *
+ * Free games are announced whether or not they are on the wishlist: a free copy of something the
+ * user has not saved is exactly the thing they would want to hear about.
+ */
+data class FreeGameAlert(
+ val identityKey: String,
+ val title: String,
+ val store: StoreId,
+ val identity: GameIdentity?,
+ val storeUrl: String,
+ val thumbUrl: String,
+)
+
+/**
+ * What the app already knows, so the same thing is not announced twice.
+ *
+ * ## Why this exists
+ *
+ * A check runs on a timer, and timers repeat. Without a record of what was already announced, the
+ * second run would announce exactly the same sales again, and the user would turn the feature off
+ * within a day. The fingerprint is the fix: a sale is announced when its *price and discount*
+ * change, not merely when the game is still discounted.
+ *
+ * [announcedSales] maps a wishlist entry's key to the fingerprint of the sale last announced for
+ * it. [seenFreeGames] holds the ids of free games already announced.
+ *
+ * [initialised] is separate from the two collections because they are legitimately empty on a
+ * first run with nothing on sale. It is what distinguishes "we have never looked" from "we looked
+ * and there was nothing", which is the difference between announcing the current state and
+ * announcing a change to it.
+ */
+data class WatchState(
+ val initialised: Boolean = false,
+ val announcedSales: Map = emptyMap(),
+ val seenFreeGames: Set = emptySet(),
+) {
+ companion object {
+ /**
+ * How many free-game ids to remember.
+ *
+ * Bounded so the file cannot grow without limit. Old ids fall off the end, and a game that
+ * reappears after a long absence would be announced again — which is arguably right, since
+ * it is free again.
+ */
+ const val MAX_SEEN_FREE_GAMES = 400
+ }
+}
+
+/**
+ * The result of one check: what to announce, and what to remember for next time.
+ */
+data class Detection(
+ val sales: List = emptyList(),
+ val freeGames: List = emptyList(),
+ val nextState: WatchState = WatchState(),
+) {
+ val hasAnythingToAnnounce: Boolean get() = sales.isNotEmpty() || freeGames.isNotEmpty()
+}
diff --git a/app/src/main/java/com/discounthunter/android/alerts/AlertNotifier.kt b/app/src/main/java/com/discounthunter/android/alerts/AlertNotifier.kt
new file mode 100644
index 0000000..1c1190b
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/AlertNotifier.kt
@@ -0,0 +1,244 @@
+package com.discounthunter.android.alerts
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.core.app.NotificationManagerCompat
+import com.discounthunter.android.MainActivity
+import com.discounthunter.android.R
+import com.discounthunter.android.core.model.FxRates
+import com.discounthunter.android.core.model.GameIdentity
+import com.discounthunter.android.core.model.PriceFormat
+import com.discounthunter.android.diagnostics.AppLog
+import com.discounthunter.android.ui.navigation.Destinations
+
+/**
+ * Posts the notifications.
+ *
+ * ## Two channels, deliberately
+ *
+ * A sale on something the user saved and a free game are different kinds of news, and a user who
+ * wants one often does not want the other. Separate channels let them silence one and keep the
+ * other, which is not possible when everything arrives on one channel.
+ *
+ * ## The content
+ *
+ * A notification that says "a deal is on" is useless. Every one of these carries the **name**, the
+ * **new price** and the **discount**, because that is what the user has to act on — and it says
+ * what the price is in, rather than leaving the reader to work it out.
+ *
+ * Converted prices are marked with a `~`, exactly as they are in the app. A converted price is an
+ * estimate, and a notification that presents it as a quoted price is lying in the one place the
+ * user is most likely to act on it.
+ */
+class AlertNotifier(
+ private val context: Context,
+ /** Rates for prices a store quoted in another currency. Null until they load. */
+ private val fx: FxRates? = null,
+) {
+
+ private val manager = NotificationManagerCompat.from(context)
+
+ /** Creates the channels. Safe to call repeatedly; the system ignores a duplicate. */
+ fun ensureChannels() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val system = context.getSystemService(NotificationManager::class.java) ?: return
+
+ val sales = NotificationChannel(
+ CHANNEL_SALES,
+ context.getString(R.string.alert_channel_sales),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = context.getString(R.string.alert_channel_sales_description)
+ enableVibration(true)
+ setShowBadge(true)
+ }
+
+ val free = NotificationChannel(
+ CHANNEL_FREE,
+ context.getString(R.string.alert_channel_free),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = context.getString(R.string.alert_channel_free_description)
+ enableVibration(true)
+ setShowBadge(true)
+ }
+
+ system.createNotificationChannel(sales)
+ system.createNotificationChannel(free)
+ }
+
+ /** True when the user has allowed notifications. */
+ fun canPost(): Boolean = hasPermission() && manager.areNotificationsEnabled()
+
+ /**
+ * The runtime permission check.
+ *
+ * `areNotificationsEnabled()` alone is not enough on Android 13 and above: the permission has
+ * to have been granted, and the system will refuse the post with a `SecurityException` if it
+ * has not. Checking it explicitly is also what stops lint — correctly — from assuming the
+ * call is safe.
+ */
+ private fun hasPermission(): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+
+ /**
+ * Announces everything in [detection].
+ *
+ * Returns how many notifications were posted, which the log records so a user reporting
+ * "I got no notification" can be checked against what the app thought it did.
+ */
+ fun announce(detection: Detection, currencySymbol: String, currencyCode: String): Int {
+ ensureChannels()
+ if (!canPost()) {
+ AppLog.w("Alerts: notifications are disabled, so nothing was announced")
+ return 0
+ }
+
+ var posted = 0
+ for (sale in detection.sales) {
+ post(saleNotification(sale, currencySymbol, currencyCode), sale.identityKey)
+ posted++
+ }
+ for (free in detection.freeGames) {
+ post(freeNotification(free, currencySymbol), free.identityKey)
+ posted++
+ }
+ if (posted > 0) AppLog.i("Alerts: announced $posted notification(s)")
+ return posted
+ }
+
+ private fun post(notification: Notification, tag: String) {
+ // Checked here rather than only once at the top of [announce]. The permission can be
+ // revoked between the two, and a background job that posts without checking crashes the
+ // app rather than skipping a notification.
+ if (!canPost()) {
+ AppLog.w("Alerts: notifications are not allowed, so a notification was skipped")
+ return
+ }
+ // The explicit catch is not defensive padding. The permission can be revoked between the
+ // check above and the call below - the user can do it from system settings while the
+ // background job is running - and the system answers that with a SecurityException. In a
+ // background job that would be an unhandled exception and a crash report for what is
+ // really a revoked permission.
+ try {
+ manager.notify(tag, NOTIFICATION_ID, notification)
+ } catch (error: SecurityException) {
+ AppLog.w("Alerts: the notification permission was revoked: ${error.message}")
+ } catch (error: Exception) {
+ AppLog.e("Alerts: could not post a notification", error)
+ }
+ }
+
+ private fun saleNotification(
+ sale: SaleAlert,
+ currencySymbol: String,
+ currencyCode: String,
+ ): Notification {
+ val percent = sale.discountPercent.toInt().coerceIn(0, 100)
+ val price = PriceFormat.priceWith(sale.price, currencyCode, sale.currencyCode, fx)
+ val original = PriceFormat.priceWith(sale.originalPrice, currencyCode, sale.currencyCode, fx)
+
+ val text = if (sale.hasOriginalPrice) {
+ context.getString(
+ R.string.alert_sale_body,
+ price,
+ original,
+ sale.store.displayName,
+ )
+ } else {
+ context.getString(R.string.alert_sale_body_no_original, price, sale.store.displayName)
+ }
+
+ return builder(CHANNEL_SALES, sale.title)
+ .setContentTitle(context.getString(R.string.alert_sale_title, sale.title, percent))
+ .setContentText(text)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
+ .setContentIntent(openIntent(sale.identity, sale.title))
+ .build()
+ }
+
+ private fun freeNotification(free: FreeGameAlert, currencySymbol: String): Notification {
+ val text = context.getString(R.string.alert_free_body, free.store.displayName)
+ return builder(CHANNEL_FREE, free.title)
+ .setContentTitle(context.getString(R.string.alert_free_title, free.title))
+ .setContentText(text)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
+ .setContentIntent(openIntent(free.identity, free.title))
+ .build()
+ }
+
+ private fun builder(channel: String, title: String): NotificationCompat.Builder =
+ NotificationCompat.Builder(context, channel)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(false)
+ .setGroup(GROUP_KEY)
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
+
+ /**
+ * Opens the game the notification is about.
+ *
+ * The game's identity travels in **intent extras**, not in a URI. A URI in a notification's
+ * intent was measured arriving with `data == null` — the intent that reached the activity was
+ * not the one that had been built — and the notification therefore opened the app without
+ * going anywhere. Extras are copied verbatim, so they cannot be dropped like that.
+ *
+ * The **title** comes along too, and it is not decoration: without it the detail page has to
+ * look the game up by name, and for GOG that means a catalogue search, which returns
+ * unrelated products for anything but a distinctive title — so the page would show the wrong
+ * offer, or none. The title is a hint, never an identity: the id still decides which game this
+ * is.
+ *
+ * A game with no usable identity falls back to opening the app itself, which is better than a
+ * notification that does nothing when tapped.
+ */
+ private fun openIntent(identity: GameIdentity?, title: String): PendingIntent {
+ val intent = if (identity != null && identity.id.isNotBlank()) {
+ Intent(context, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ putExtra(Destinations.EXTRA_STORE, identity.store.name)
+ putExtra(Destinations.EXTRA_ID, identity.id)
+ if (title.isNotBlank()) putExtra(Destinations.EXTRA_TITLE, title)
+ }
+ } else {
+ Intent(context, MainActivity::class.java)
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ return PendingIntent.getActivity(
+ context,
+ (identity?.key ?: title).hashCode(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ companion object {
+ const val CHANNEL_SALES = "discount_hunter_sales"
+ const val CHANNEL_FREE = "discount_hunter_free"
+
+ /** All the notifications share one id so the system collapses them into a summary. */
+ private const val NOTIFICATION_ID = 4200
+ private const val GROUP_KEY = "discount_hunter_alerts"
+ }
+}
+
+
diff --git a/app/src/main/java/com/discounthunter/android/alerts/BootReceiver.kt b/app/src/main/java/com/discounthunter/android/alerts/BootReceiver.kt
new file mode 100644
index 0000000..a608c1a
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/BootReceiver.kt
@@ -0,0 +1,38 @@
+package com.discounthunter.android.alerts
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.discounthunter.android.diagnostics.AppLog
+
+/**
+ * Restores the schedule after a reboot.
+ *
+ * Alarms do not survive a restart, and WorkManager's own schedule *does* — so this is not about
+ * the periodic worker, which would come back on its own. It is about the alarm heartbeat, which
+ * is the punctual half of the arrangement and would otherwise be silently gone after every
+ * reboot.
+ *
+ * The worker is re-registered as well, because the user's setting is the source of truth for
+ * whether monitoring should be running at all: if they turned it off, a reboot must not turn it
+ * back on.
+ */
+class BootReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) {
+ return
+ }
+
+ AppLog.i("Alerts: the device started, restoring the check schedule")
+ val pending = goAsync()
+ val appContext = context.applicationContext
+ // The work is queued rather than done inline: on a boot broadcast the device may still be
+ // finishing its own start-up, and a network call then would be competing with everything
+ // else the system is doing.
+ DealAlertScheduler.enqueueNow(appContext)
+ DealAlertScheduler.scheduleAlarm(appContext)
+ pending.finish()
+ }
+}
diff --git a/app/src/main/java/com/discounthunter/android/alerts/DealAlertReceiver.kt b/app/src/main/java/com/discounthunter/android/alerts/DealAlertReceiver.kt
new file mode 100644
index 0000000..9ccea64
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/DealAlertReceiver.kt
@@ -0,0 +1,29 @@
+package com.discounthunter.android.alerts
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.discounthunter.android.diagnostics.AppLog
+
+/**
+ * Receives the periodic alarm and turns it into a queued check.
+ *
+ * The receiver does no work of its own beyond handing the job to WorkManager. That is deliberate:
+ * a broadcast receiver has about ten seconds before the system treats it as hung, and fetching a
+ * live feed from three stores takes longer than that. Handing over immediately means the check
+ * runs under WorkManager's background execution guarantees instead.
+ *
+ * The next alarm is armed as the receiver runs, so the chain continues without the app ever being
+ * opened.
+ */
+class DealAlertReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != DealAlertScheduler.ACTION_CHECK) return
+ AppLog.i("Alerts: the alarm fired")
+ // Re-arm first. If the process is killed between here and the work being queued, the next
+ // alarm is already in place.
+ DealAlertScheduler.scheduleAlarm(context)
+ DealAlertScheduler.enqueueNow(context)
+ }
+}
diff --git a/app/src/main/java/com/discounthunter/android/alerts/DealAlertScheduler.kt b/app/src/main/java/com/discounthunter/android/alerts/DealAlertScheduler.kt
new file mode 100644
index 0000000..7b65cc2
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/DealAlertScheduler.kt
@@ -0,0 +1,189 @@
+package com.discounthunter.android.alerts
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import com.discounthunter.android.diagnostics.AppLog
+import java.util.concurrent.TimeUnit
+
+/**
+ * Decides when the deal check happens.
+ *
+ * ## Two mechanisms, on purpose
+ *
+ * The single most common reason an app's notifications "do not always work" is that it relies on
+ * **one** mechanism, and that mechanism has a hole:
+ *
+ * * **WorkManager** is the officially supported way to do background work, and it survives
+ * reboots. But a *periodic* worker is a request, not a promise: the system batches and defers
+ * it, Doze pushes it out, and several manufacturers' battery managers delay it further. A
+ * notification that arrives four hours late is the inconsistency users complain about.
+ * * **AlarmManager** is punctual, and `setExactAndAllowWhileIdle` is delivered inside a Doze
+ * maintenance window rather than being batched. But alarms do not survive a reboot on their own,
+ * and an app that is force-stopped loses them.
+ *
+ * So both are used. The alarm is the **punctual** path and WorkManager's periodic worker is the
+ * **guaranteed** backstop, and both end up enqueueing the same idempotent worker. Whichever fires
+ * first does the work; the other is a no-op, because the state that decides what to announce is
+ * written as part of the same run.
+ *
+ * ## The exact-alarm permission
+ *
+ * `SCHEDULE_EXACT_ALARM` is needed for `setExactAndAllowWhileIdle` on Android 12 and above, and
+ * the user can refuse it. Rather than treating that as a failure, the scheduler asks
+ * [AlarmManager.canScheduleExactAlarms] and falls back to `setAndAllowWhileIdle`, which needs no
+ * permission and still works inside Doze. A less punctual notification is much better than none.
+ */
+object DealAlertScheduler {
+
+ /** The name of the periodic worker. Unique, so it is never scheduled twice. */
+ const val PERIODIC_WORK = "discount_hunter_deal_check"
+
+ /** The name of the one-off run the alarm triggers. */
+ const val ONE_TIME_WORK = "discount_hunter_deal_check_now"
+
+ /** How often the check should run. */
+ const val CHECK_INTERVAL_MINUTES = 60L
+
+ /**
+ * Starts monitoring.
+ *
+ * Safe to call repeatedly: the periodic worker uses [ExistingPeriodicWorkPolicy.KEEP], so an
+ * existing schedule is left alone rather than restarted on every app launch.
+ */
+ fun start(context: Context) {
+ val workManager = WorkManager.getInstance(context)
+
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED)
+ .build()
+
+ val periodic = PeriodicWorkRequestBuilder(
+ CHECK_INTERVAL_MINUTES, TimeUnit.MINUTES,
+ // A flex window lets the system pick the best moment inside the interval, which is
+ // both kinder to the battery and better for Doze.
+ 15, TimeUnit.MINUTES,
+ )
+ .setConstraints(constraints)
+ .setBackoffCriteria(androidx.work.BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
+ .addTag(PERIODIC_WORK)
+ .build()
+
+ workManager.enqueueUniquePeriodicWork(
+ PERIODIC_WORK,
+ ExistingPeriodicWorkPolicy.KEEP,
+ periodic,
+ )
+
+ scheduleAlarm(context)
+ AppLog.i("Alerts: monitoring scheduled (every $CHECK_INTERVAL_MINUTES min)")
+ }
+
+ /** Stops monitoring and cancels anything already queued. */
+ fun stop(context: Context) {
+ runCatching { WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK) }
+ runCatching { WorkManager.getInstance(context).cancelUniqueWork(ONE_TIME_WORK) }
+ cancelAlarm(context)
+ AppLog.i("Alerts: monitoring stopped")
+ }
+
+ /**
+ * Schedules the next alarm.
+ *
+ * Called from [DealAlertReceiver] after each run, so the alarm keeps itself alive. The
+ * receiver re-arms rather than using a repeating alarm, because a repeating alarm is lost the
+ * moment the app is updated and has to be re-armed anyway.
+ */
+ fun scheduleAlarm(context: Context) {
+ val manager = context.getSystemService(AlarmManager::class.java) ?: return
+ val triggerAt = System.currentTimeMillis() + CHECK_INTERVAL_MINUTES * 60_000L
+
+ val pending = alarmIntent(context)
+ try {
+ if (canScheduleExact(manager)) {
+ manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pending)
+ } else {
+ // Inexact, but still delivered inside a Doze window, and it needs no permission.
+ manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pending)
+ }
+ } catch (error: SecurityException) {
+ // The exact-alarm permission was revoked between the check and the call.
+ AppLog.w("Alerts: exact alarms are not permitted, falling back to an inexact alarm")
+ runCatching { manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pending) }
+ }
+ }
+
+ /** Cancels the pending alarm. */
+ fun cancelAlarm(context: Context) {
+ val manager = context.getSystemService(AlarmManager::class.java) ?: return
+ runCatching { manager.cancel(alarmIntent(context)) }
+ }
+
+ /**
+ * Queues the check to run now.
+ *
+ * The work goes through WorkManager rather than being done inside the receiver, because a
+ * broadcast receiver has roughly ten seconds before the system considers it hung, and a store
+ * feed takes longer than that. WorkManager runs it properly, off the main thread, with the
+ * process allowed to be started for it.
+ *
+ * ## Why this is not a unique work name
+ *
+ * Both obvious policies are wrong, and both were wrong in practice before this was settled:
+ *
+ * * `REPLACE` cancels whatever is already running. The alarm and the periodic worker can
+ * fire within seconds of each other, and that turned into a cancellation storm where each
+ * new request killed the one that had just started, so no check ever completed.
+ * * `KEEP` never cancels, but a work that is stuck in a queued state — waiting for a network
+ * that is not coming, say — blocks every later request under the same name. The checks
+ * then stop happening, silently, and nothing says why.
+ *
+ * So the request is simply enqueued. Overlapping runs are harmless: the notification for a
+ * game is posted under that game's own tag and id, so a second post *replaces* the first
+ * rather than adding to the shade. The user sees one notification either way.
+ */
+ fun enqueueNow(context: Context) {
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED)
+ .build(),
+ )
+ .setBackoffCriteria(androidx.work.BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
+ .addTag(ONE_TIME_WORK)
+ .build()
+
+ runCatching { WorkManager.getInstance(context).enqueue(request) }
+ .onFailure { AppLog.e("Alerts: could not queue a check", it) }
+ }
+
+ private fun alarmIntent(context: Context): PendingIntent {
+ val intent = Intent(context, DealAlertReceiver::class.java).setAction(ACTION_CHECK)
+ return PendingIntent.getBroadcast(
+ context,
+ REQUEST_CODE,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun canScheduleExact(manager: AlarmManager): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ runCatching { manager.canScheduleExactAlarms() }.getOrDefault(false)
+ } else {
+ true
+ }
+
+ /** The action the alarm carries. */
+ const val ACTION_CHECK = "com.discounthunter.android.action.CHECK_DEALS"
+ private const val REQUEST_CODE = 7311
+}
diff --git a/app/src/main/java/com/discounthunter/android/alerts/DealAlertWorker.kt b/app/src/main/java/com/discounthunter/android/alerts/DealAlertWorker.kt
new file mode 100644
index 0000000..417d661
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/DealAlertWorker.kt
@@ -0,0 +1,135 @@
+package com.discounthunter.android.alerts
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.discounthunter.android.DiscountHunterApplication
+import com.discounthunter.android.data.wishlist.representsSame
+import com.discounthunter.android.diagnostics.AppLog
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+
+/**
+ * Checks for new sales and new free games, and announces them.
+ *
+ * ## Why a worker rather than a timer in the app
+ *
+ * A `Timer` or a coroutine on the app's own scope dies with the process. This worker does not: it
+ * is run by the system, which starts the app if needed, runs it within the background execution
+ * limits, and reschedules it after a reboot. That is the whole reason the notification arrives
+ * when the app has never been opened since.
+ *
+ * The work is **idempotent**. It can be triggered by the periodic schedule, by the alarm
+ * heartbeat, or by both at once, and the outcome does not depend on which: what gets announced is
+ * decided by comparing against [WatchState], and the state is written as part of the same run.
+ * Overlapping runs therefore announce nothing twice.
+ */
+class DealAlertWorker(
+ context: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
+ // Two triggers can fire within seconds of each other - the alarm and the periodic worker -
+ // and the work is enqueued rather than made unique, so two runs really can overlap. A
+ // process-wide lock means the second one waits for the first instead of fetching the same
+ // feed a second time and racing it to write the state.
+ //
+ // The lock is taken with `withLock`, so a cancelled run releases it.
+ mutex.withLock { runCheck() }
+ }
+
+ private suspend fun runCheck(): Result {
+ val application = applicationContext as? DiscountHunterApplication
+ val container = application?.container
+ if (container == null) {
+ AppLog.w("Alerts: no container available, skipping this check")
+ return Result.retry()
+ }
+
+ try {
+ val settings = container.settings.settings.first()
+ if (!settings.saleAlertsEnabled) {
+ AppLog.i("Alerts: monitoring is off, skipping this check")
+ return Result.success()
+ }
+
+ // The wishlist lives in memory and is mirrored from DataStore. A worker may be the very
+ // first thing to run in a fresh process, so it has to be loaded rather than assumed.
+ container.wishlist.loadOnce()
+ val wishlist = container.wishlist.entries.value
+ if (wishlist.isEmpty()) {
+ AppLog.i("Alerts: the wishlist is empty, nothing to watch for")
+ return Result.success()
+ }
+
+ val feed = container.deals.refreshFeed(
+ country = settings.steamCountry,
+ // A larger feed than the app shows: a wishlisted game at a shallow discount would
+ // otherwise sit below the cut-off and never be seen.
+ limit = maxOf(settings.dealLimit, MINIMUM_DEAL_LIMIT),
+ stores = settings.enabledStores,
+ )
+ if (feed.deals.isEmpty()) {
+ AppLog.w("Alerts: the feed came back empty, so nothing could be checked")
+ // Not a failure: the stores may simply be down. Retrying immediately would hammer
+ // them, so this is a success with no work done.
+ return Result.success()
+ }
+
+ val store = WatchStateStore(container.watchStateFile)
+ val previous = store.load()
+ val detection = SaleDetector.detect(
+ wishlist = wishlist,
+ deals = feed.deals,
+ state = previous,
+ matches = { entry, deal -> representsSame(entry, deal.wishlistKey, deal.titleKey) },
+ )
+
+ val notifier = AlertNotifier(applicationContext, container.fxRates)
+ val posted = notifier.announce(
+ detection = detection,
+ currencySymbol = settings.currencySymbol,
+ currencyCode = settings.currency.code,
+ )
+
+ // The state is written whether or not anything was announced. Skipping the write when
+ // there is nothing to say would lose the record of what has been seen, and the next
+ // check would announce everything again.
+ store.save(detection.nextState)
+ AppLog.i(
+ "Alerts: checked ${feed.deals.size} deals, announced $posted, " +
+ "watching ${wishlist.size} saved game(s)",
+ )
+ return Result.success()
+ } catch (cancellation: kotlinx.coroutines.CancellationException) {
+ throw cancellation
+ } catch (error: Exception) {
+ AppLog.e("Alerts: the check failed", error)
+ // Retry, but not in a tight loop: WorkManager backs off on its own.
+ return Result.retry()
+ }
+ }
+
+ private companion object {
+ /**
+ * Serialises overlapping checks within the process.
+ *
+ * Process-wide rather than per-instance because WorkManager creates a new worker object
+ * for every run, so an instance-level lock would not actually exclude anything.
+ */
+ val mutex = kotlinx.coroutines.sync.Mutex()
+ /**
+ * The floor on how many deals to fetch.
+ *
+ * A wishlisted game on sale at a shallow discount can easily fall below the app's own
+ * cut-off of 40 per store, and then it would never be noticed at all. Fetching a little
+ * more is cheap and closes most of that gap.
+ */
+ const val MINIMUM_DEAL_LIMIT = 60
+ }
+}
+
+
diff --git a/app/src/main/java/com/discounthunter/android/alerts/SaleDetector.kt b/app/src/main/java/com/discounthunter/android/alerts/SaleDetector.kt
new file mode 100644
index 0000000..c984610
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/SaleDetector.kt
@@ -0,0 +1,155 @@
+package com.discounthunter.android.alerts
+
+import com.discounthunter.android.core.model.Deal
+import com.discounthunter.android.core.model.WishlistEntry
+
+/**
+ * Works out what changed since the last check.
+ *
+ * ## The rule
+ *
+ * A check produces the same list of deals over and over, so "what is on sale" is not the question.
+ * The question is **"what is different from last time"**, and there are exactly two kinds of
+ * difference worth telling someone about:
+ *
+ * * a game on the wishlist that has **started** being discounted, and
+ * * a game that has **become free**.
+ *
+ * Both are transitions, not states. A wishlisted game that is still 50% off next hour is not news,
+ * and announcing it again would train the user to swipe the notification away without reading it.
+ *
+ * ## Absence is not evidence
+ *
+ * When a wishlisted game is missing from the feed, its fingerprint is **left alone**. The feed only
+ * holds the top few dozen discounts per store, so a game dropping out of it says nothing about
+ * whether it is still discounted. Clearing the record on absence would mean the game could
+ * announce itself again the next time it appeared, unchanged.
+ *
+ * The record is only cleared when the game is **seen and is not discounted** — that is the one
+ * observation that actually proves the sale ended, and therefore that a later discount is news.
+ *
+ * ## The first run
+ *
+ * The very first check has nothing to compare against, so it announces nothing. It records what it
+ * saw instead, and from then on only changes are announced.
+ *
+ * That is the honest reading of "new free games" and "goes on sale": without a previous
+ * observation, nothing can be shown to have *become* anything. It also avoids a first-run flood of
+ * one notification per free game currently on offer, which is how a feature like this gets turned
+ * off before it is ever used.
+ */
+object SaleDetector {
+
+ /**
+ * Compares [deals] against [state] and the user's [wishlist].
+ *
+ * [matches] decides whether a deal is the same game as a wishlist entry. It is injected so the
+ * rule lives in exactly one place — [com.discounthunter.android.data.wishlist.representsSame]
+ * — and so this function stays pure and testable without a repository.
+ */
+ fun detect(
+ wishlist: List,
+ deals: List,
+ state: WatchState,
+ matches: (entry: WishlistEntry, deal: Deal) -> Boolean,
+ ): Detection {
+ val firstRun = !state.initialised
+ val announced = state.announcedSales.toMutableMap()
+ val seenFree = LinkedHashSet(state.seenFreeGames)
+ val sales = mutableListOf()
+ val freeGames = mutableListOf()
+
+ // ---- wishlist games that have started being discounted ----
+ for (entry in wishlist) {
+ val deal = deals.firstOrNull { matches(entry, it) } ?: continue
+
+ if (deal.isFree) {
+ // A free game is announced by the free-game path below, which covers wishlisted
+ // games too. Announcing it here as well would tell the user twice about the same
+ // thing, once as "is free" and once as "100% off" — and the second one is the
+ // less useful of the two.
+ //
+ // The record is cleared, so that if the game later becomes a *paid* discount, that
+ // is announced as a sale.
+ announced.remove(entry.key)
+ continue
+ }
+
+ if (deal.savingsPercent <= 0.0) {
+ // Seen, and not discounted: the sale has ended, so a later one is news again.
+ announced.remove(entry.key)
+ continue
+ }
+
+ val fingerprint = fingerprint(deal)
+ if (announced[entry.key] == fingerprint) continue
+
+ announced[entry.key] = fingerprint
+ if (!firstRun) {
+ sales += SaleAlert(
+ identityKey = entry.key,
+ title = deal.title.ifBlank { entry.title },
+ store = deal.store,
+ identity = deal.identity ?: entry.identityOrNull(),
+ price = deal.salePrice,
+ originalPrice = deal.normalPrice,
+ discountPercent = deal.savingsPercent,
+ currencyCode = deal.currencyCode,
+ storeUrl = deal.storeUrl,
+ thumbUrl = deal.thumbUrl,
+ )
+ }
+ }
+
+ // ---- games that have become free ----
+ for (deal in deals) {
+ if (!deal.isFree) continue
+ val key = deal.id
+ if (!seenFree.add(key)) continue
+ if (!firstRun) {
+ freeGames += FreeGameAlert(
+ identityKey = key,
+ title = deal.title,
+ store = deal.store,
+ identity = deal.identity,
+ storeUrl = deal.storeUrl,
+ thumbUrl = deal.thumbUrl,
+ )
+ }
+ }
+
+ // Bound the free-game memory. The insertion order is preserved, so the oldest ids are the
+ // ones dropped.
+ val trimmed = if (seenFree.size > WatchState.MAX_SEEN_FREE_GAMES) {
+ seenFree.toList().takeLast(WatchState.MAX_SEEN_FREE_GAMES).toSet()
+ } else {
+ seenFree
+ }
+
+ return Detection(
+ sales = sales,
+ freeGames = freeGames,
+ nextState = WatchState(
+ initialised = true,
+ announcedSales = announced,
+ seenFreeGames = trimmed,
+ ),
+ )
+ }
+
+ /**
+ * Identifies a sale.
+ *
+ * The price and the percentage are both in it, so a re-listed game at a different price
+ * announces itself again — which is the point — while the same sale seen twice does not.
+ */
+ private fun fingerprint(deal: Deal): String =
+ "${deal.id}|${deal.salePrice}|${deal.savingsPercent}"
+
+ /** The wishlist entry's own identity, when it has one. */
+ private fun WishlistEntry.identityOrNull() =
+ com.discounthunter.android.core.model.GameIdentity(
+ store = store,
+ id = appId?.toString() ?: gogSlug,
+ ).takeIf { it.id.isNotBlank() }
+}
diff --git a/app/src/main/java/com/discounthunter/android/alerts/WatchStateStore.kt b/app/src/main/java/com/discounthunter/android/alerts/WatchStateStore.kt
new file mode 100644
index 0000000..1f44969
--- /dev/null
+++ b/app/src/main/java/com/discounthunter/android/alerts/WatchStateStore.kt
@@ -0,0 +1,109 @@
+package com.discounthunter.android.alerts
+
+import com.discounthunter.android.core.text.Json
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import java.io.File
+
+/**
+ * Remembers what has already been announced.
+ *
+ * ## Why a file and not DataStore
+ *
+ * DataStore is right for settings the user changes. This is a small machine-written record of what
+ * the app has already told the user, it is written from a background worker, and it must never
+ * block a notification. A plain file, written atomically and read leniently, is a better fit: it
+ * cannot deadlock the notification path behind a DataStore write.
+ *
+ * ## Failure is always safe
+ *
+ * A corrupt or missing file reads as [WatchState.initialised] `false`, which means the next check
+ * re-establishes the baseline instead of announcing a flood. Losing the record costs at most one
+ * missed change; it can never cause a wrong price to be announced.
+ */
+class WatchStateStore(
+ private val file: File,
+ private val io: CoroutineDispatcher = Dispatchers.IO,
+) {
+
+ /** Reads the record, treating anything unreadable as "never checked". */
+ suspend fun load(): WatchState = withContext(io) {
+ if (!file.isFile) return@withContext WatchState()
+ val text = runCatching { file.readText() }.getOrNull() ?: return@withContext WatchState()
+ parse(text)
+ }
+
+ /** Writes the record. A failure here is logged and swallowed: the next check re-reads the old
+ * one, and the worst case is a repeat announcement rather than a crash in a background job. */
+ suspend fun save(state: WatchState) = withContext(io) {
+ runCatching {
+ file.parentFile?.mkdirs()
+ // Written to a sibling and moved into place, so a crash mid-write cannot leave a
+ // half-written file that reads as corrupt.
+ val temp = File(file.parentFile, "${file.name}.tmp")
+ temp.writeText(serialize(state))
+ if (!temp.renameTo(file)) {
+ file.writeText(serialize(state))
+ temp.delete()
+ }
+ }
+ }
+
+ private fun parse(text: String): WatchState {
+ val root = Json.parse(text) as? JsonObject ?: return WatchState()
+ val sales = mutableMapOf()
+ (root["announcedSales"] as? JsonObject)?.forEach { (key, value) ->
+ val fingerprint = (value as? JsonPrimitive)?.content ?: return@forEach
+ sales[key] = fingerprint
+ }
+ val free = LinkedHashSet()
+ (root["seenFreeGames"] as? JsonArray)?.forEach { element ->
+ (element as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }?.let { free += it }
+ }
+ return WatchState(
+ initialised = (root["initialised"] as? JsonPrimitive)?.content == "true",
+ announcedSales = sales,
+ seenFreeGames = free,
+ )
+ }
+
+ private fun serialize(state: WatchState): String = buildString {
+ append('{')
+ append("\"initialised\":").append(state.initialised)
+ append(",\"announcedSales\":{")
+ var first = true
+ for ((key, value) in state.announcedSales) {
+ if (!first) append(',')
+ first = false
+ append('"').append(escape(key)).append("\":\"").append(escape(value)).append('"')
+ }
+ append("},\"seenFreeGames\":[")
+ first = true
+ for (key in state.seenFreeGames) {
+ if (!first) append(',')
+ first = false
+ append('"').append(escape(key)).append('"')
+ }
+ append("]}")
+ }
+
+ /** Escapes the two characters that would break the JSON. Keys are store ids and fingerprints,
+ * so in practice they contain neither — but a store id with a quote in it must not corrupt
+ * the whole file. */
+ private fun escape(value: String): String = buildString {
+ for (character in value) {
+ when (character) {
+ '"' -> append("\\\"")
+ '\\' -> append("\\\\")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ else -> append(character)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/discounthunter/android/data/settings/AppSettings.kt b/app/src/main/java/com/discounthunter/android/data/settings/AppSettings.kt
index 547f89a..c2b13d8 100644
--- a/app/src/main/java/com/discounthunter/android/data/settings/AppSettings.kt
+++ b/app/src/main/java/com/discounthunter/android/data/settings/AppSettings.kt
@@ -28,6 +28,14 @@ data class AppSettings(
val autoplayTrailers: Boolean = false,
/** Prefer a store's own app over the browser when both are available. */
val preferStoreApps: Boolean = true,
+ /**
+ * Whether to check for sales and free games in the background.
+ *
+ * On by default. The point of the feature is to be told about a discount the user is not
+ * looking at the app for, and a setting that stays off until it is discovered is a setting
+ * nobody ever turns on.
+ */
+ val saleAlertsEnabled: Boolean = true,
val recentSearches: List = emptyList(),
val linkedAccounts: Map = emptyMap(),
val wishlist: List = emptyList(),
diff --git a/app/src/main/java/com/discounthunter/android/data/settings/SettingsRepository.kt b/app/src/main/java/com/discounthunter/android/data/settings/SettingsRepository.kt
index 4076a5d..307bcb3 100644
--- a/app/src/main/java/com/discounthunter/android/data/settings/SettingsRepository.kt
+++ b/app/src/main/java/com/discounthunter/android/data/settings/SettingsRepository.kt
@@ -64,6 +64,9 @@ class SettingsRepository(context: Context) {
suspend fun setPreferStoreApps(enabled: Boolean) = edit { it[KEY_STORE_APPS] = enabled }
+ /** Turns the background sale and free-game announcements on or off. */
+ suspend fun setSaleAlertsEnabled(enabled: Boolean) = edit { it[KEY_SALE_ALERTS] = enabled }
+
suspend fun rememberSearch(query: String) = edit { preferences ->
val trimmed = query.trim()
if (trimmed.isEmpty()) return@edit
@@ -105,6 +108,7 @@ class SettingsRepository(context: Context) {
.coerceIn(AppSettings.MIN_DEAL_LIMIT, AppSettings.MAX_DEAL_LIMIT),
autoplayTrailers = this[KEY_AUTOPLAY] ?: false,
preferStoreApps = this[KEY_STORE_APPS] ?: true,
+ saleAlertsEnabled = this[KEY_SALE_ALERTS] ?: true,
recentSearches = decodeSearches(this[KEY_SEARCHES]),
linkedAccounts = decodeAccounts(this[KEY_ACCOUNTS]),
wishlist = decodeWishlist(this[KEY_WISHLIST]),
@@ -220,6 +224,7 @@ class SettingsRepository(context: Context) {
val KEY_DEAL_LIMIT = intPreferencesKey("deal_limit")
val KEY_AUTOPLAY = booleanPreferencesKey("autoplay_trailers")
val KEY_STORE_APPS = booleanPreferencesKey("prefer_store_apps")
+ val KEY_SALE_ALERTS = booleanPreferencesKey("sale_alerts_enabled")
val KEY_SEARCHES = stringPreferencesKey("recent_searches")
val KEY_ACCOUNTS = stringPreferencesKey("linked_accounts")
val KEY_WISHLIST = stringPreferencesKey("wishlist")
diff --git a/app/src/main/java/com/discounthunter/android/data/wishlist/WishlistRepository.kt b/app/src/main/java/com/discounthunter/android/data/wishlist/WishlistRepository.kt
index 45fc9d6..aca574b 100644
--- a/app/src/main/java/com/discounthunter/android/data/wishlist/WishlistRepository.kt
+++ b/app/src/main/java/com/discounthunter/android/data/wishlist/WishlistRepository.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -60,14 +61,21 @@ class WishlistRepository(
}
}
- /** Seeds the in-memory list without keeping a collector alive (used by tests). */
+ /**
+ * Seeds the in-memory list from storage, without keeping a collector alive.
+ *
+ * Used by the background check, which may be the first thing to run in a fresh process, and
+ * by the tests.
+ *
+ * It reads the current value with [first] rather than collecting. Collecting a DataStore flow
+ * never returns — the flow stays open for as long as the process lives — so a caller that
+ * awaited this would wait forever. The background check did exactly that, and the check
+ * silently never completed: the worker started, blocked here, and produced neither a
+ * notification nor an error.
+ */
suspend fun loadOnce() {
- val snapshot = settings.settings.map { it.wishlist }.distinctUntilChanged()
- var first: List? = null
- snapshot.collect { value ->
- first = value.map { it.toEntry() }
- }
- rebuild(first.orEmpty())
+ val snapshot = settings.settings.map { it.wishlist }.distinctUntilChanged().first()
+ rebuild(snapshot.map { it.toEntry() })
}
/** True when the game described by [exactKey] / [titleKey] is saved. */
diff --git a/app/src/main/java/com/discounthunter/android/di/AppContainer.kt b/app/src/main/java/com/discounthunter/android/di/AppContainer.kt
index f1eeada..384a385 100644
--- a/app/src/main/java/com/discounthunter/android/di/AppContainer.kt
+++ b/app/src/main/java/com/discounthunter/android/di/AppContainer.kt
@@ -1,6 +1,9 @@
package com.discounthunter.android.di
import android.content.Context
+import com.discounthunter.android.alerts.AlertNotifier
+import com.discounthunter.android.alerts.DealAlertScheduler
+import com.discounthunter.android.alerts.WatchStateStore
import com.discounthunter.android.core.model.FxRates
import com.discounthunter.android.data.cache.PayloadCache
import com.discounthunter.android.data.cache.SnapshotStore
@@ -20,6 +23,7 @@ import com.discounthunter.android.data.wishlist.WishlistRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.io.File
@@ -43,7 +47,9 @@ class AppContainer(
val applicationScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
- private val appContext: Context = context.applicationContext
+ /** The application context. Public because the background check and the Settings screen both
+ * need it, and neither of them should ever hold an Activity. */
+ val appContext: Context = context.applicationContext
/** One HTTP client for store traffic and artwork, so sockets and timeouts stay bounded. */
val httpClient: OkHttpClient = HttpClient.buildDefault()
@@ -113,6 +119,17 @@ class AppContainer(
val wishlist: WishlistRepository = WishlistRepository(settings, applicationScope)
+ /**
+ * Where the record of what has already been announced is kept.
+ *
+ * In `filesDir`, not `cacheDir`: the system may clear the cache at any time, and losing this
+ * record would mean announcing every current sale again.
+ */
+ val watchStateFile: File = File(appContext.filesDir, "alerts/watch-state.json")
+
+ /** The store behind the background announcements. */
+ val watchStateStore = WatchStateStore(watchStateFile)
+
// The services are wrapped in their source interfaces so the repositories depend on a small,
// testable surface rather than on the full network stack.
private val steam = SteamSourceAdapter(SteamService(network, payloadCache))
@@ -152,5 +169,32 @@ class AppContainer(
// network, and a failure here leaves prices labelled in their own currency, which is a
// usable fallback rather than a broken screen.
applicationScope.launch { fxRates.ensure() }
+
+ // The background check. Started here rather than only when the user opens Settings, so the
+ // feature works from the moment it is enabled - the user's setting is the source of truth,
+ // and this is the one place that knows about it.
+ applicationScope.launch {
+ val enabled = runCatching { settings.settings.first() }.getOrNull()?.saleAlertsEnabled
+ if (enabled == true) {
+ AlertNotifier(appContext, fxRates).ensureChannels()
+ DealAlertScheduler.start(appContext)
+ }
+ }
+ }
+
+ /**
+ * Turns the background announcements on or off, and applies the change immediately.
+ *
+ * The schedule lives outside the app's process, so changing the setting has to reach it
+ * explicitly - otherwise the user would switch the feature off and keep getting notifications.
+ */
+ suspend fun setSaleAlertsEnabled(enabled: Boolean) {
+ settings.setSaleAlertsEnabled(enabled)
+ if (enabled) {
+ AlertNotifier(appContext, fxRates).ensureChannels()
+ DealAlertScheduler.start(appContext)
+ } else {
+ DealAlertScheduler.stop(appContext)
+ }
}
}
diff --git a/app/src/main/java/com/discounthunter/android/ui/navigation/Destinations.kt b/app/src/main/java/com/discounthunter/android/ui/navigation/Destinations.kt
index bdd1f7d..b515f1a 100644
--- a/app/src/main/java/com/discounthunter/android/ui/navigation/Destinations.kt
+++ b/app/src/main/java/com/discounthunter/android/ui/navigation/Destinations.kt
@@ -36,6 +36,18 @@ object Destinations {
fun isDetail(route: String?): Boolean = route?.startsWith("detail/") == true
+ /**
+ * Extras that carry a game into the app.
+ *
+ * Used by the notification tap. The identity travels in **extras** rather than in a URI
+ * because a URI in a notification's intent was measured arriving with `data == null` — the
+ * intent the activity received was not the one that had been built. Extras are copied
+ * verbatim and cannot be dropped that way.
+ */
+ const val EXTRA_STORE = "com.discounthunter.android.extra.STORE"
+ const val EXTRA_ID = "com.discounthunter.android.extra.ID"
+ const val EXTRA_TITLE = "com.discounthunter.android.extra.TITLE"
+
/** The four bottom-bar destinations, in order. */
val topLevel: List = listOf(DEALS, SEARCH, WISHLIST, SETTINGS)
}
diff --git a/app/src/main/java/com/discounthunter/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/discounthunter/android/ui/settings/SettingsScreen.kt
index 4e26cf8..64983de 100644
--- a/app/src/main/java/com/discounthunter/android/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/discounthunter/android/ui/settings/SettingsScreen.kt
@@ -27,6 +27,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import android.os.Build
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -76,6 +79,24 @@ fun SettingsScreen(
var confirmClear by remember { mutableStateOf(false) }
var confirmDeleteAll by remember { mutableStateOf(false) }
+ // The notification permission is requested from the screen, because a ViewModel has no
+ // activity to attach a permission dialog to. The request is made when the user switches the
+ // feature on, which is the moment the permission actually starts to matter.
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) {
+ viewModel.refreshNotificationPermission()
+ }
+
+ val requestNotifications = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ viewModel.refreshNotificationPermission()
+ }
+ Unit
+ }
+
// Re-probe installed store apps whenever the screen becomes visible.
LaunchedEffect(Unit) {
viewModel.refreshStoreApps()
@@ -198,6 +219,46 @@ fun SettingsScreen(
)
}
+ // ---- deal alerts ----
+ //
+ // Its own card rather than another switch under "Deal feed", because this is the only
+ // feature in the app that does something *when the app is not running*, and it deserves
+ // to say so.
+ SettingsCard(title = stringResource(R.string.settings_alerts_title)) {
+ SwitchRow(
+ title = stringResource(R.string.settings_alerts_title),
+ subtitle = stringResource(R.string.settings_alerts_summary),
+ checked = state.settings.saleAlertsEnabled,
+ onCheckedChange = { enabled ->
+ if (enabled) requestNotifications()
+ viewModel.setSaleAlertsEnabled(enabled)
+ },
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.settings_alerts_note),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ // The switch can be on and the feature still be unable to do anything, because
+ // Android 13 and above need notifications to be granted explicitly. Saying so, and
+ // offering the one place it can be changed, is the difference between a setting
+ // that works and a setting that quietly does nothing.
+ if (state.settings.saleAlertsEnabled && !state.notificationsAllowed) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.settings_alerts_permission_needed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(onClick = viewModel::openNotificationSettings) {
+ Text(stringResource(R.string.settings_alerts_open_settings))
+ }
+ }
+ }
+
// ---- accounts ----
SettingsCard(title = "Store accounts") {
Text(
diff --git a/app/src/main/java/com/discounthunter/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/discounthunter/android/ui/settings/SettingsViewModel.kt
index 9d7dfc7..a385e82 100644
--- a/app/src/main/java/com/discounthunter/android/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/discounthunter/android/ui/settings/SettingsViewModel.kt
@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.discounthunter.android.alerts.AlertNotifier
import com.discounthunter.android.core.model.StoreId
import com.discounthunter.android.data.settings.AppSettings
import com.discounthunter.android.data.settings.CurrencyCode
@@ -33,6 +34,14 @@ data class SettingsUiState(
val isDeletingData: Boolean = false,
/** Whether a store app that handles its deep links is installed. */
val storeApps: Map = emptyMap(),
+ /**
+ * Whether the app is allowed to post notifications.
+ *
+ * False on Android 12 and below only if the user blocked them in system settings; on Android 13
+ * and above it is false until the permission is granted. The deal-alerts switch can be on
+ * while this is false, which is exactly the state that makes the feature look broken.
+ */
+ val notificationsAllowed: Boolean = true,
)
/**
@@ -52,20 +61,42 @@ class SettingsViewModel(
private val clearing = MutableStateFlow(false)
private val deleting = MutableStateFlow(false)
private val detectedApps = MutableStateFlow(StoreId.MAJOR.associateWith { false })
+ private val notificationsAllowed = MutableStateFlow(true)
- val uiState: StateFlow = combine(
- settingsRepository.settings,
+ /**
+ * The state this screen owns itself, as opposed to state that comes from the repository.
+ *
+ * Grouped into one flow because `combine` has typed overloads for up to five flows, and this
+ * screen has more state than that once the notification permission is added.
+ */
+ private data class LocalFlags(
+ val cacheSize: Long,
+ val clearing: Boolean,
+ val deleting: Boolean,
+ val notificationsAllowed: Boolean,
+ )
+
+ private val localFlags = combine(
cacheSize,
clearing,
- detectedApps,
deleting,
- ) { settings, size, isClearing, apps, isDeleting ->
+ notificationsAllowed,
+ ) { size, isClearing, isDeleting, allowed ->
+ LocalFlags(size, isClearing, isDeleting, allowed)
+ }
+
+ val uiState: StateFlow = combine(
+ settingsRepository.settings,
+ detectedApps,
+ localFlags,
+ ) { settings, apps, flags ->
SettingsUiState(
settings = settings,
- cacheSizeBytes = size,
- isClearingCache = isClearing,
- isDeletingData = isDeleting,
+ cacheSizeBytes = flags.cacheSize,
+ isClearingCache = flags.clearing,
+ isDeletingData = flags.deleting,
storeApps = apps,
+ notificationsAllowed = flags.notificationsAllowed,
)
}.stateIn(
scope = viewModelScope,
@@ -78,6 +109,7 @@ class SettingsViewModel(
.onEach { measureCache() }
.launchIn(viewModelScope)
refreshStoreApps()
+ refreshNotificationPermission()
}
// ------------------------------------------------------------------ actions
@@ -102,6 +134,46 @@ class SettingsViewModel(
fun setPreferStoreApps(enabled: Boolean) =
viewModelScope.launch { settingsRepository.setPreferStoreApps(enabled) }
+ /**
+ * Turns the background announcements on or off.
+ *
+ * The change is applied to the schedule as well as to the setting. The schedule lives outside
+ * the app's process, so writing the setting alone would leave the checks running and the
+ * notifications arriving after the user had switched the feature off.
+ *
+ * The notification permission is requested by the screen, not here: a ViewModel has no
+ * activity to ask, and reaching for one is how a permission dialog ends up attached to
+ * nothing.
+ */
+ fun setSaleAlertsEnabled(enabled: Boolean) = viewModelScope.launch {
+ container.setSaleAlertsEnabled(enabled)
+ refreshNotificationPermission()
+ }
+
+ /**
+ * Opens the system notification settings for this app.
+ *
+ * Once the permission has been refused, Android does not show the dialog again — the only way
+ * back is the settings screen, so that is where the button leads.
+ */
+ fun openNotificationSettings() {
+ val context = container.appContext
+ val intent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
+ .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
+ } else {
+ android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(android.net.Uri.fromParts("package", context.packageName, null))
+ }
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ runCatching { context.startActivity(intent) }
+ }
+
+ /** Re-reads whether notifications are allowed, so the screen stops warning once they are. */
+ fun refreshNotificationPermission() {
+ notificationsAllowed.value = AlertNotifier(container.appContext).canPost()
+ }
+
fun linkAccount(store: StoreId, name: String?) =
viewModelScope.launch { settingsRepository.setLinkedAccount(store, name) }
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..6c984ab
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fddbe20..afc9e2d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -123,4 +123,19 @@
Cache cleared.
Calculating…
+
+ Deal alerts
+ Tell me when a saved game goes on sale, and when a game becomes free to keep.
+ Checked about once an hour in the background, including when the app is closed. Only changes are announced: a game that is still on sale tomorrow is not announced again. A game that is already on sale when you turn this on is not announced either — the check starts from now.
+ Notifications are blocked for Discount Hunter, so alerts cannot be shown. Allow them in the system settings.
+ Open settings
+ Sales on your wishlist
+ A game you saved has started being discounted.
+ Free games
+ A game has become free to keep.
+ %1$s is %2$d%% off
+ Now %1$s (was %2$s) on %3$s
+ Now %1$s on %2$s
+ %1$s is free
+ Free to keep on %1$s. Claim it before it is gone.
diff --git a/app/src/test/java/com/discounthunter/android/alerts/SaleDetectorTest.kt b/app/src/test/java/com/discounthunter/android/alerts/SaleDetectorTest.kt
new file mode 100644
index 0000000..68fcd68
--- /dev/null
+++ b/app/src/test/java/com/discounthunter/android/alerts/SaleDetectorTest.kt
@@ -0,0 +1,409 @@
+package com.discounthunter.android.alerts
+
+import com.discounthunter.android.core.model.Deal
+import com.discounthunter.android.core.model.GameIdentity
+import com.discounthunter.android.core.model.StoreId
+import com.discounthunter.android.core.model.WishlistEntry
+import com.discounthunter.android.data.wishlist.representsSame
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * What gets announced, and what does not.
+ *
+ * ## The behaviour this pins down
+ *
+ * The check runs on a timer, so the same deals are seen over and over. The rule is that only a
+ * **change** is worth telling someone about:
+ *
+ * * a wishlisted game that has *started* being discounted;
+ * * a game that has *become* free.
+ *
+ * A game that is still discounted next hour is not news, and announcing it again is how a user
+ * learns to swipe the notification away without reading it.
+ *
+ * ## Why the keys come from the model
+ *
+ * The wishlist entries here are built with [WishlistEntry.fromDeal], so their keys are exactly
+ * the ones production produces. Writing the keys out by hand — `"s1:1"` — would pass for reasons
+ * unrelated to the code, and would keep passing even after the key format changed. That is the
+ * worst property a test can have.
+ */
+class SaleDetectorTest {
+
+ private fun deal(
+ id: String,
+ title: String,
+ price: Double = 9.99,
+ original: Double = 19.99,
+ percent: Double = 50.0,
+ store: StoreId = StoreId.STEAM,
+ free: Boolean = false,
+ /**
+ * The currency the store quoted it in.
+ *
+ * Blank means the store re-quotes for the user's region. GOG never does — its catalogue
+ * answers in dollars whatever is asked for — so a GOG deal carries `"USD"` in production,
+ * set by the service. This mirrors that, so the test exercises the same shape the app
+ * really produces.
+ */
+ currencyCode: String = "",
+ ) = Deal(
+ id = "${store.name.lowercase()}_$id",
+ title = title,
+ thumbUrl = "",
+ salePrice = if (free) 0.0 else price,
+ normalPrice = if (free) 0.0 else original,
+ savingsPercent = if (free) 100.0 else percent,
+ store = store,
+ identity = GameIdentity(store, id),
+ storeUrl = "",
+ currencyCode = currencyCode,
+ )
+
+ /** A wishlist entry for the same game, keyed exactly as production would key it. */
+ private fun saved(deal: Deal): WishlistEntry = WishlistEntry.fromDeal(deal, 0L)
+
+ /** The production matching rule, used unchanged. */
+ private val matches: (WishlistEntry, Deal) -> Boolean = { entry, deal ->
+ representsSame(entry, deal.wishlistKey, deal.titleKey)
+ }
+
+ private fun detect(
+ wishlist: List,
+ deals: List,
+ state: WatchState,
+ ) = SaleDetector.detect(wishlist, deals, state, matches)
+
+ // ------------------------------------------------------------------ first run
+
+ @Test
+ fun `the first run announces nothing`() {
+ // There is no previous observation, so nothing can be shown to have *become* anything.
+ val elden = deal("1", "Elden Ring")
+ val result = detect(
+ wishlist = listOf(saved(elden)),
+ deals = listOf(elden),
+ state = WatchState(),
+ )
+
+ assertTrue(result.sales.isEmpty())
+ assertTrue(result.freeGames.isEmpty())
+ }
+
+ @Test
+ fun `the first run records what it saw`() {
+ val elden = deal("1", "Elden Ring")
+ val result = detect(listOf(saved(elden)), listOf(elden), WatchState())
+
+ assertTrue(result.nextState.initialised)
+ assertEquals(1, result.nextState.announcedSales.size)
+ }
+
+ @Test
+ fun `the first run does not announce the free games already on offer`() {
+ // Otherwise enabling the feature produces one notification per free game currently
+ // available, which is how a feature like this gets switched off before it is used.
+ val result = detect(
+ wishlist = emptyList(),
+ deals = listOf(deal("1", "Free One", free = true), deal("2", "Free Two", free = true)),
+ state = WatchState(),
+ )
+
+ assertTrue(result.freeGames.isEmpty())
+ assertEquals(2, result.nextState.seenFreeGames.size)
+ }
+
+ // ------------------------------------------------------------------ sales
+
+ @Test
+ fun `a game that starts being discounted is announced`() {
+ val notYet = deal("1", "Elden Ring", percent = 0.0)
+ val entry = saved(notYet)
+
+ val before = detect(listOf(entry), listOf(notYet), WatchState())
+ val after = detect(
+ wishlist = listOf(entry),
+ deals = listOf(deal("1", "Elden Ring", percent = 50.0)),
+ state = before.nextState,
+ )
+
+ assertEquals(1, after.sales.size)
+ assertEquals("Elden Ring", after.sales.first().title)
+ assertEquals(50.0, after.sales.first().discountPercent, 0.001)
+ assertEquals(9.99, after.sales.first().price, 0.001)
+ }
+
+ @Test
+ fun `the same sale is not announced twice`() {
+ val entry = saved(deal("1", "Elden Ring"))
+ val deals = listOf(deal("1", "Elden Ring", percent = 50.0))
+
+ val first = detect(listOf(entry), deals, WatchState())
+ val second = detect(listOf(entry), deals, first.nextState)
+
+ assertTrue(second.sales.isEmpty())
+ }
+
+ @Test
+ fun `a deeper discount is announced again`() {
+ val entry = saved(deal("1", "Elden Ring"))
+
+ val first = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", price = 9.99, percent = 50.0)),
+ WatchState(),
+ )
+ val second = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", price = 4.99, percent = 75.0)),
+ first.nextState,
+ )
+
+ assertEquals(1, second.sales.size)
+ assertEquals(75.0, second.sales.first().discountPercent, 0.001)
+ }
+
+ @Test
+ fun `a sale that ended and starts again is announced again`() {
+ val entry = saved(deal("1", "Elden Ring"))
+
+ val onSale = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 50.0)),
+ WatchState(),
+ )
+ val offSale = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 0.0)),
+ onSale.nextState,
+ )
+ val backOnSale = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 50.0)),
+ offSale.nextState,
+ )
+
+ assertEquals(1, backOnSale.sales.size)
+ }
+
+ @Test
+ fun `a game missing from the feed is not treated as no longer on sale`() {
+ // The feed only holds the top few dozen discounts per store. A game dropping out of it
+ // says nothing about whether it is still discounted, so the record has to survive.
+ val entry = saved(deal("1", "Elden Ring"))
+ val onSale = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 50.0)),
+ WatchState(),
+ )
+ val absent = detect(listOf(entry), emptyList(), onSale.nextState)
+
+ assertEquals(1, absent.nextState.announcedSales.size)
+ }
+
+ @Test
+ fun `a game that disappears and comes back at the same price is not announced again`() {
+ val entry = saved(deal("1", "Elden Ring"))
+ val onSale = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 50.0)),
+ WatchState(),
+ )
+ val absent = detect(listOf(entry), emptyList(), onSale.nextState)
+ val back = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 50.0)),
+ absent.nextState,
+ )
+
+ assertTrue(back.sales.isEmpty())
+ }
+
+ @Test
+ fun `a game on the wishlist that is not discounted is not announced`() {
+ val entry = saved(deal("1", "Elden Ring"))
+ val result = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 0.0)),
+ WatchState(initialised = true),
+ )
+
+ assertTrue(result.sales.isEmpty())
+ }
+
+ @Test
+ fun `a game that is not on the wishlist is not announced as a sale`() {
+ val entry = saved(deal("1", "Elden Ring"))
+ val result = detect(
+ listOf(entry),
+ listOf(deal("2", "Some Other Game", percent = 90.0)),
+ WatchState(initialised = true),
+ )
+
+ assertTrue(result.sales.isEmpty())
+ }
+
+ @Test
+ fun `the sale carries the price and the discount`() {
+ // The user asked to be told both, and a notification without them is useless.
+ val entry = saved(deal("1", "Elden Ring"))
+ val before = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", percent = 0.0)),
+ WatchState(),
+ )
+ val after = detect(
+ listOf(entry),
+ listOf(deal("1", "Elden Ring", price = 24.99, original = 99.99, percent = 75.0)),
+ before.nextState,
+ )
+
+ val sale = after.sales.first()
+ assertEquals(24.99, sale.price, 0.001)
+ assertEquals(99.99, sale.originalPrice, 0.001)
+ assertEquals(75.0, sale.discountPercent, 0.001)
+ assertTrue(sale.hasOriginalPrice)
+ }
+
+ @Test
+ fun `the sale keeps the currency the store quoted it in`() {
+ val gog = deal("some-slug", "Some Long Game Name", store = StoreId.GOG, currencyCode = "USD")
+ val entry = saved(gog)
+ val before = detect(
+ listOf(entry),
+ listOf(
+ deal("some-slug", "Some Long Game Name", store = StoreId.GOG, percent = 0.0, currencyCode = "USD"),
+ ),
+ WatchState(),
+ )
+ val after = detect(
+ listOf(entry),
+ listOf(
+ deal("some-slug", "Some Long Game Name", store = StoreId.GOG, percent = 50.0, currencyCode = "USD"),
+ ),
+ before.nextState,
+ )
+
+ // A GOG price is in dollars whatever region is asked for, and the notification has to
+ // know that or it will present it in the wrong currency.
+ assertEquals("USD", after.sales.first().currencyCode)
+ }
+
+ @Test
+ fun `a free game on the wishlist is announced once, not twice`() {
+ // Announcing it as a free game *and* as "100% off" tells the user about the same thing
+ // twice, and the second version is the less useful one.
+ val free = deal("1", "Agent A", free = true)
+ val entry = saved(free)
+
+ val result = detect(listOf(entry), listOf(free), WatchState(initialised = true))
+
+ assertTrue(result.sales.isEmpty())
+ assertEquals(1, result.freeGames.size)
+ }
+
+ @Test
+ fun `a wishlisted free game that becomes a paid discount is announced as a sale`() {
+ val free = deal("1", "Agent A", free = true)
+ val entry = saved(free)
+
+ val wasFree = detect(listOf(entry), listOf(free), WatchState(initialised = true))
+ val nowDiscounted = detect(
+ listOf(entry),
+ listOf(deal("1", "Agent A", price = 9.99, percent = 50.0)),
+ wasFree.nextState,
+ )
+
+ assertEquals(1, nowDiscounted.sales.size)
+ }
+
+ // ------------------------------------------------------------------ free games
+
+ @Test
+ fun `a new free game is announced`() {
+ val before = detect(emptyList(), emptyList(), WatchState(initialised = true))
+ val after = detect(
+ wishlist = emptyList(),
+ deals = listOf(deal("1", "Free One", free = true)),
+ state = before.nextState,
+ )
+
+ assertEquals(1, after.freeGames.size)
+ assertEquals("Free One", after.freeGames.first().title)
+ }
+
+ @Test
+ fun `a free game that stays free is not announced again`() {
+ val before = detect(emptyList(), emptyList(), WatchState(initialised = true))
+ val first = detect(
+ wishlist = emptyList(),
+ deals = listOf(deal("1", "Free One", free = true)),
+ state = before.nextState,
+ )
+ val second = detect(
+ wishlist = emptyList(),
+ deals = listOf(deal("1", "Free One", free = true)),
+ state = first.nextState,
+ )
+
+ assertTrue(second.freeGames.isEmpty())
+ }
+
+ @Test
+ fun `a free game is announced whether or not it is on the wishlist`() {
+ // A free copy of something the user has not saved is exactly the thing they would want
+ // to hear about.
+ val before = detect(emptyList(), emptyList(), WatchState(initialised = true))
+ val after = detect(
+ wishlist = emptyList(),
+ deals = listOf(deal("1", "Free One", free = true)),
+ state = before.nextState,
+ )
+
+ assertEquals(1, after.freeGames.size)
+ }
+
+ @Test
+ fun `a discounted game is not announced as free`() {
+ val before = detect(emptyList(), emptyList(), WatchState(initialised = true))
+ val after = detect(
+ wishlist = emptyList(),
+ deals = listOf(deal("1", "On Sale", percent = 50.0)),
+ state = before.nextState,
+ )
+
+ assertTrue(after.freeGames.isEmpty())
+ }
+
+ @Test
+ fun `the free game memory is bounded`() {
+ val before = detect(emptyList(), emptyList(), WatchState(initialised = true))
+ val many = (1..(WatchState.MAX_SEEN_FREE_GAMES + 50)).map {
+ deal(it.toString(), "Free $it", free = true)
+ }
+ val after = detect(emptyList(), many, before.nextState)
+
+ assertEquals(WatchState.MAX_SEEN_FREE_GAMES, after.nextState.seenFreeGames.size)
+ }
+
+ // ------------------------------------------------------------------ idempotence
+
+ @Test
+ fun `running the same check twice announces nothing the second time`() {
+ // The schedule has two independent triggers, and they can both fire. The outcome must not
+ // depend on which one won the race.
+ val elden = deal("1", "Elden Ring", percent = 50.0)
+ val wishlist = listOf(saved(elden))
+ val deals = listOf(elden, deal("2", "Free One", free = true))
+
+ val first = detect(wishlist, deals, WatchState())
+ val second = detect(wishlist, deals, first.nextState)
+
+ assertTrue(second.sales.isEmpty())
+ assertTrue(second.freeGames.isEmpty())
+ assertFalse(second.hasAnythingToAnnounce)
+ }
+}
diff --git a/docs/ENGINEERING.md b/docs/ENGINEERING.md
index c2becaa..b990266 100644
--- a/docs/ENGINEERING.md
+++ b/docs/ENGINEERING.md
@@ -6,7 +6,7 @@ Kotlin + Jetpack Compose, Material 3, single activity, bottom navigation, no Web
The port keeps the behaviour of the PC app and fixes the bugs that were found in it while porting —
most importantly the one where a game's detail page showed **another game's screenshots and
trailers**. It also fixes a handful of bugs that only appear on a real device, listed at the bottom
-of this file and detailed in [DEV_NOTES.md](../DEV_NOTES.md).
+of this file and detailed in [DEV_NOTES.md](DEV_NOTES.md).
---
@@ -374,7 +374,31 @@ Run a single suite:
hardened instead: the star's pop was keyed on `isSaved`, so every tap cancelled a running spring
and queued another. It is now keyed on a tick that only advances when the pop should play.
Verified with ~150 rapid toggles, including 60 at 25 ms intervals on one row.
-Full write-up with the reasoning in [DEV_NOTES.md](../DEV_NOTES.md).
+43. **The background 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 and no crash report. It
+ now reads the current value with `first()`.
+44. **Two scheduling policies, both wrong.** `REPLACE` cancelled the in-flight check, and because
+ the alarm and the periodic worker can fire together that became a cancellation storm where 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. The request is now simply
+ enqueued; overlapping runs are harmless because a notification posted under the same tag and id
+ replaces itself.
+45. **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.
+46. **A free game was announced twice** — as "is free" and as "100% off". A free deal is now
+ skipped by the sale path, and its record cleared so a later paid discount is still announced.
+47. **A notification's deep link showed the wrong price.** Without a title the detail page looks a
+ GOG game up with a catalogue search, which returns unrelated products for anything but a
+ distinctive name. The title now travels with the link, and the page shows the same price the
+ notification did.
+48. **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. Fixed with two keep rules, and the release APK is now
+ installed and launched on a device before the work is called done.
+Full write-up with the reasoning in [DEV_NOTES.md](DEV_NOTES.md).
---
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2d6e081..723d1a4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ coroutines = "1.10.2"
okhttp = "4.12.0"
serializationJson = "1.8.0"
media3 = "1.5.1"
+work = "2.10.0"
junit = "4.13.2"
androidxTestJunit = "1.3.0"
@@ -56,6 +57,8 @@ androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplaye
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
+androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
+
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }