Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 48 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
60 changes: 59 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
131 changes: 131 additions & 0 deletions DEV_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WishlistEntry>? = 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.<init> []
```

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 { <init>(); }
-keep class androidx.work.impl.** { *; }
```
---

## 5. Design decisions worth explaining
Expand All @@ -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
Expand Down
Loading
Loading