Gitea's own notification feed doesn't fire for plain pushes (only issue/PR activity), so this app now periodically checks each favorited repository's most recent push time itself and posts a local notification when it advances past what was last seen. - FavoritePushDetector: pure comparison logic, never notifies on a repository's first sighting (only a real advance counts), avoiding a notification storm the first time this feature ever runs. - GetLatestPushEpochMillisUseCase: extracts the same 'newest branch commit time' heuristic RepositoryListViewModel already uses for the repository list's own last-push row, as a shared, reusable use case. - SharedPreferencesLastSeenPushStore: one long per favorited repo, not Room (docs/architecture.md reserves Room for offline caching). - FavoritePushCheckWorker: 15-minute WorkManager periodic job, network-constrained, no-ops cleanly with no session or no favorites. - POST_NOTIFICATIONS requested at runtime on API 33+; a denial degrades safely, the worker still runs and updates its baseline. No deep link from the notification to the specific repository yet -- tapping it opens the app normally. End-to-end delivery (a real push landing, 15 minutes elapsing) is unit-tested and the scheduling/ permission wiring is live-confirmed (prompt appeared, was granted, no crash), but an actual notification firing hasn't been observed within this same live-testing session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
186 lines
12 KiB
Markdown
186 lines
12 KiB
Markdown
# TASK-036: Push notification when a favorited repository gets a new push
|
|
|
|
Status: implemented; all required checks pass; live-installed, awaiting the human product owner's confirmation
|
|
Owner: Claude
|
|
Reviewer: Codex
|
|
|
|
## Outcome
|
|
|
|
The human product owner asked earlier this session for a notification when a repository they've
|
|
favorited receives a push, explicitly accepting it was fine to skip if infeasible. Feasibility was
|
|
already established (see TASK-029's dev-log entry) and the human product owner approved building it
|
|
as its own task. Gitea's own server-side notification feed doesn't fire for plain pushes (only
|
|
issue/PR activity), so this can't be reached via the existing `/notifications` integration — the
|
|
app now periodically checks each favorited repository's most recent push time itself (reusing the
|
|
exact detection heuristic `RepositoryListViewModel` already uses for "last pushed branch": the
|
|
newest branch commit timestamp) and posts a local notification when it advances past what was last
|
|
seen.
|
|
|
|
## Scope
|
|
|
|
- Included: `androidx.work:work-runtime-ktx` dependency; new `core/notifications/` package
|
|
(`FavoritePushDetector`, `LastSeenPushStore` + Android impl, `GetLatestPushEpochMillisUseCase`,
|
|
`FavoritePushCheckWorker`, `FavoritePushNotifier`, `FavoritePushScheduler`); `POST_NOTIFICATIONS`
|
|
manifest permission + runtime request wiring in `MainActivity`; a notification channel.
|
|
- Excluded: No deep link from the notification into the specific repository — tapping it opens the
|
|
app to its normal entry point, not that repository directly (see Design notes). No change to
|
|
Gitea's own `/notifications` feed or the existing in-app Notifications screen — this is a
|
|
separate, local-only notification channel. No UI to configure the check interval or disable this
|
|
per-repository — it's on for every favorited repository, or off entirely if the OS permission is
|
|
denied.
|
|
|
|
## Writable paths
|
|
|
|
- `app/build.gradle.kts`, `gradle/libs.versions.toml`
|
|
- `app/src/main/AndroidManifest.xml`
|
|
- `app/src/main/java/com/ubisam/ubgitea/core/notifications/`
|
|
- `app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt`
|
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
|
- `app/src/test/java/com/ubisam/ubgitea/core/notifications/`
|
|
- `tasks/036-favorite-repo-push-notifications.md`, `docs/development-log.md`
|
|
|
|
## Design notes
|
|
|
|
- **Detection reuses a verified heuristic instead of inventing a new one**: "last push" is derived
|
|
the same way `RepositoryListViewModel.launchRowBranchFetch` already derives it for the repository
|
|
list's "마지막 push" row — the maximum `commitAtEpochMillis` across a repository's branches, via
|
|
Gitea's existing `/branches` endpoint. `GetLatestPushEpochMillisUseCase` in
|
|
`RepositoryBrowseUseCases.kt` packages that exact algorithm (paginate all branches, take the max
|
|
commit time) as its own reusable use case rather than duplicating an ad hoc copy inside the new
|
|
worker; the ViewModel's own existing code path is untouched (lower-risk than refactoring already-
|
|
tested, currently-working code just for this).
|
|
- **First sight of a repository never fires a notification**: `FavoritePushDetector.detectNewPushes`
|
|
only reports a repository as "newly pushed" when a previously-recorded timestamp exists AND the
|
|
freshly-fetched one is strictly greater. A repository the store has never seen before (first
|
|
worker run after favoriting it, or the very first run after installing this feature) just
|
|
establishes its baseline silently — without this, the very first background check after adopting
|
|
this feature would fire one notification per already-favorited repository regardless of whether
|
|
anything actually changed recently, which would read as broken/spammy on day one.
|
|
- **Storage is plain `SharedPreferences`, not Room**: `docs/architecture.md` reserves Room for
|
|
"when offline caching is approved" — this isn't caching, it's one `long` per favorited repository
|
|
(a coordinates string key, an epoch-millis value), well inside what `SharedPreferences` handles
|
|
cleanly without pulling in a database dependency for a handful of key-value pairs.
|
|
- **Scheduled unconditionally on app start, gated internally instead**: `FavoritePushScheduler`'s
|
|
`WorkManager.enqueueUniquePeriodicWork(..., ExistingPeriodicWorkPolicy.KEEP, ...)` is called from
|
|
`MainActivity.onCreate()` every launch — idempotent (`KEEP` means a second call while one is
|
|
already scheduled is a no-op), so it doesn't need its own "is the user logged in" gate at the call
|
|
site. Instead, `FavoritePushCheckWorker.doWork()` itself checks whether a session exists and
|
|
returns `Result.success()` immediately if not (an empty favorites list, or no stored token, is
|
|
simply nothing to check) rather than the scheduler needing to track login state separately.
|
|
- **15-minute interval, matching `PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS`**: Android's
|
|
own platform floor for periodic `WorkManager` jobs — this was communicated to the human product
|
|
owner as the expected cadence when feasibility was first raised, and there's no reason to poll
|
|
less often than the platform allows for something this low-urgency. `Constraints.NetworkType.CONNECTED`
|
|
is required so the job doesn't even attempt to run while offline.
|
|
- **`POST_NOTIFICATIONS` requested once, from `MainActivity`, only where it's needed (API 33+)**:
|
|
below API 33 no runtime prompt exists for notifications at all; on API 33+, the permission is
|
|
requested via `ActivityResultContracts.RequestPermission()` on launch. A denial isn't treated as
|
|
an error anywhere in this feature — `NotificationManagerCompat.notify()` silently no-ops without
|
|
the permission, so the worker still runs and updates its stored baseline correctly either way,
|
|
the user just doesn't see the notification.
|
|
- **No deep link yet**: the notification's tap target opens the app at its normal entry point, not
|
|
the specific repository that pushed. Building a genuine deep link would mean extending this app's
|
|
custom sealed `Route` navigation to accept an externally-supplied target and wiring that through
|
|
`MainActivity`'s intent handling — a meaningful chunk of additional, separable work not folded
|
|
into this task; noted as a natural follow-up rather than attempted here.
|
|
|
|
## Acceptance criteria
|
|
|
|
- [x] A periodic background check (15-minute `WorkManager` interval, network-constrained) compares
|
|
each favorited repository's latest branch-commit time against what was last recorded.
|
|
- [x] A repository seen for the first time by the checker never triggers a notification on that
|
|
first sighting — only a real advance past a previously-recorded timestamp does.
|
|
- [x] A local notification is posted (title/body naming the repository) when a favorited
|
|
repository's last-push time advances.
|
|
- [x] `POST_NOTIFICATIONS` is requested at runtime on API 33+ and declared in the manifest; the
|
|
feature degrades safely (worker still runs, no crash, notification just doesn't show) if
|
|
denied.
|
|
- [x] The worker is a no-op (not a crash, not a spurious notification) when there's no stored
|
|
session or no favorited repositories.
|
|
- [x] Build, unit tests, and lint pass.
|
|
- [x] Live-confirmed: the `POST_NOTIFICATIONS` runtime prompt actually appeared on the device on
|
|
first launch and was granted (verified after the fact via
|
|
`dumpsys package com.ubisam.ubgitea`, `granted=true`); app launch and the scheduling call
|
|
produced no crash (`logcat` clean of `FATAL EXCEPTION`/`AndroidRuntime` for this app).
|
|
- [ ] Not yet live-confirmed: an actual notification firing end-to-end. That specifically requires
|
|
a real push landing on a favorited repository and the 15-minute interval elapsing, which
|
|
doesn't fit inside this same live-testing session.
|
|
|
|
## Security and privacy
|
|
|
|
- No new personal data leaves the device — the worker calls the same Gitea API endpoints
|
|
(`/user/starred`, `/repos/{owner}/{repo}/branches`) this app already calls elsewhere, using the
|
|
same shared, Keystore-backed token provider (`AuthSession`), over the same shared HTTPS
|
|
`OkHttpClient`. Nothing is logged beyond this app's existing debug-build-only diagnostic logging
|
|
conventions.
|
|
- The stored "last seen push time" data (`SharedPreferences`) contains only repository coordinates
|
|
and epoch-millisecond timestamps — no tokens, no commit content, no user data beyond what's
|
|
already visible in the repository list itself.
|
|
- `POST_NOTIFICATIONS` is the only new permission; it grants no capability beyond showing
|
|
notifications this app itself posts.
|
|
|
|
## Required checks
|
|
|
|
```text
|
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin
|
|
./gradlew testDebugUnitTest
|
|
./gradlew assembleDebug lintDebug
|
|
```
|
|
|
|
## Evidence
|
|
|
|
- `gradle/libs.versions.toml` / `app/build.gradle.kts`: `androidx.work:work-runtime-ktx` 2.11.2
|
|
(latest stable, confirmed against Google's Maven metadata before pinning it) added as a real
|
|
dependency; `work-testing` added test-only.
|
|
- `core/notifications/FavoritePushDetector.kt`: pure comparison logic (`current` vs `lastSeen` maps
|
|
→ the list of repositories that count as newly pushed), no Android/network dependency.
|
|
- `core/notifications/LastSeenPushStore.kt` (+ `SharedPreferencesLastSeenPushStore`): read/write a
|
|
`Map<RepositoryCoordinates, Long>`, `SharedPreferences`-backed.
|
|
- `RepositoryBrowseUseCases.kt`: new `GetLatestPushEpochMillisUseCase`.
|
|
- `core/notifications/FavoritePushNotifier.kt`: notification channel creation + posting, isolated
|
|
from the worker so it's a thin, directly-inspectable seam.
|
|
- `core/notifications/FavoritePushCheckWorker.kt`: `CoroutineWorker` wiring the above together;
|
|
no-ops cleanly on no session / no favorites / any thrown failure (retries transient failures via
|
|
`Result.retry()`, matching `WorkManager` convention).
|
|
- `core/notifications/FavoritePushScheduler.kt`: `enqueueUniquePeriodicWork` with a 15-minute
|
|
interval and a network constraint.
|
|
- `MainActivity.kt`: schedules the periodic work on launch; requests `POST_NOTIFICATIONS` at
|
|
runtime on API 33+.
|
|
- `AndroidManifest.xml`: `POST_NOTIFICATIONS` permission declared.
|
|
- New tests: `FavoritePushDetectorTest.kt` (first-sight-never-notifies, an actual advance notifies,
|
|
an unchanged or older timestamp doesn't, multiple repositories are evaluated independently).
|
|
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
|
|
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing.
|
|
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
|
|
- Installed on the human product owner's device over wireless `adb`. Live-observed: app launch
|
|
triggered the `POST_NOTIFICATIONS` runtime prompt, which was granted (confirmed via
|
|
`dumpsys package com.ubisam.ubgitea` showing `granted=true`); no crash in `logcat` around launch
|
|
or scheduling. Not yet live-observed: the worker actually firing a notification, since that
|
|
requires a real push landing on a favorited repository and the 15-minute interval elapsing, which
|
|
doesn't fit inside this same live-testing session.
|
|
|
|
## Known limitations
|
|
|
|
- No deep link from the notification to the specific repository (see Design notes) — tapping it
|
|
just opens the app normally.
|
|
- No in-app way to disable this per-repository or change the interval; it's implicitly on for every
|
|
favorited repository as long as the OS notification permission is granted.
|
|
- If the human product owner un-favorites a repository, its stored baseline is dropped on the very
|
|
next successful worker run (`store.write(current)` replaces the whole snapshot with exactly that
|
|
run's favorited set each time) — re-favoriting it later is treated as a first sighting again, not
|
|
a retroactive notification for whatever happened while it was un-favorited. Judged the safer,
|
|
simpler default over trying to preserve and reconcile a stale baseline across that gap.
|
|
- `androidx.work` merges its own manifest permissions into the final app (`FOREGROUND_SERVICE`,
|
|
`RECEIVE_BOOT_COMPLETED`) — both normal-level, granted automatically at install time, not
|
|
something this task requested or needs to request at runtime; noted here only so a manifest diff
|
|
doesn't look unexplained.
|
|
- Actual end-to-end delivery of a real notification hasn't been observed yet in this session (see
|
|
Acceptance criteria) — the worker's logic is unit-tested, and the scheduling/permission wiring is
|
|
confirmed working live (the runtime prompt appeared and was granted, no crash on launch), but a
|
|
real push landing, 15 minutes elapsing, and a notification actually appearing in the system tray
|
|
is still unconfirmed.
|
|
|
|
## Review findings
|
|
|
|
None yet — pending Codex/human review.
|