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. Settings, including log export

+## 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" }