No task waits on a Codex review; the redesign tasks are confirmed on the product owner's Z Fold7; TASK-016's leftovers are on hold; no biometric reauthentication. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
16 KiB
Markdown
228 lines
16 KiB
Markdown
# TASK-036: Push notification when a favorited repository gets a new push
|
|
|
|
Status: implemented (including a same-session deep-link follow-up); all required checks pass; live-installed and the notification-tap navigation path is live-confirmed; the actual end-to-end trigger (a real push) still awaits 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. Tapping the notification deep-links straight into that repository's default branch (added as
|
|
a same-session follow-up once the human product owner asked to keep going — see Design notes for
|
|
why it's the default branch specifically, not necessarily the branch that was actually pushed to).
|
|
|
|
## Scope
|
|
|
|
- Included: `androidx.work:work-runtime-ktx` dependency; new `core/notifications/` package
|
|
(`FavoritePushDetector`, `LastSeenPushStore` + Android impl, `GetLatestPushEpochMillisUseCase`,
|
|
`ListFavoritedRepositoriesUseCase`, `FavoritePushCheckWorker`, `FavoritePushNotifier`,
|
|
`FavoritePushScheduler`); `POST_NOTIFICATIONS` manifest permission + runtime request wiring in
|
|
`MainActivity`; a notification channel; a deep link from the notification tap into the
|
|
repository's default branch via `MainActivity`'s existing `Route`/intent handling.
|
|
- Excluded: 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. The deep link targets the repository's *default* branch, not
|
|
necessarily the specific branch that was actually pushed to (see Design notes).
|
|
|
|
## 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/`
|
|
- `app/src/test/java/com/ubisam/ubgitea/domain/usecase/`
|
|
- `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.
|
|
- **Deep link targets the default branch, not necessarily the pushed one**: `FavoritePushDetector`
|
|
only tracks one scalar "latest push time" per repository, not which branch produced it, so there's
|
|
no specific branch name available at notify time to target exactly. `ListFavoritedRepositoriesUseCase`
|
|
was changed to return full `RepositorySummary` values (not just coordinates) specifically so
|
|
`defaultBranch` is available when building the notification — opening a repository at its default
|
|
branch is still a correct, useful "take me to this repository" action, and matches where a user
|
|
manually opening it would usually start anyway. A repository with no default branch (e.g. empty)
|
|
still gets a notification, just without deep-link extras — tapping it opens the app plainly.
|
|
- **The deep link reuses `MainActivity`'s existing `Route`/intent machinery, not a new mechanism**:
|
|
`FavoritePushNotifier`'s `PendingIntent` carries owner/name/ref as plain string extras (public
|
|
constants on its companion object, so `MainActivity` reads the exact keys it wrote rather than
|
|
duplicating literal strings on both sides); `MainActivity` already has a `consumeCallback`
|
|
pattern for reading-and-clearing one-shot intent data (originally built for the OAuth redirect) —
|
|
a new `consumeFavoritePushTarget` follows the identical shape. Since `routeStack` is Compose state
|
|
scoped inside `setContent`, not a plain Activity field, the bridge is a
|
|
`mutableStateOf<Route.BranchDetail?>` *Activity property* (works fine outside a composable — it's
|
|
just an observable holder) that a `LaunchedEffect` inside `setContent` reads and pushes onto
|
|
`routeStack` once set, then clears. Pushed onto whatever's currently showing (never resets the
|
|
stack), consistent with how every other in-app navigation here only ever pushes.
|
|
|
|
## 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] Tapping the notification (when the favorited repository has a default branch) navigates
|
|
directly to that repository's Files view at its default branch, not just to the app's normal
|
|
entry point.
|
|
- [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).
|
|
- [x] Live-confirmed: sending `MainActivity` an intent carrying the exact extras
|
|
`FavoritePushNotifier` would set (`adb shell am start` with the three
|
|
`EXTRA_FAVORITE_PUSH_*` string extras) opened the app directly at that repository's file
|
|
browser on the requested branch, screenshot-verified, no crash.
|
|
- [ ] Not yet live-confirmed: the worker itself actually firing a notification 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 — what's confirmed
|
|
above is that the deep-link *navigation* half works correctly; the trigger half is simulated,
|
|
not observed from an organically-fired notification tap.
|
|
|
|
## 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`; `ListFavoritedRepositoriesUseCase`
|
|
(returns full `RepositorySummary`, not just coordinates, so `defaultBranch` is available for the
|
|
deep link).
|
|
- `core/notifications/FavoritePushNotifier.kt`: notification channel creation + posting, isolated
|
|
from the worker so it's a thin, directly-inspectable seam; `notifyNewPush` now takes an optional
|
|
`defaultBranch` and attaches `EXTRA_OWNER`/`EXTRA_NAME`/`EXTRA_REF` string extras to the tap
|
|
`PendingIntent` when one is known.
|
|
- `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); looks up each newly-pushed repository's
|
|
`defaultBranch` before calling `notifyNewPush`.
|
|
- `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+; `pendingFavoritePushRoute` (an Activity-property `mutableStateOf`) bridges
|
|
`consumeFavoritePushTarget` (plain Activity code, `onCreate`/`onNewIntent`) into a `LaunchedEffect`
|
|
inside `setContent` that pushes the resulting `Route.BranchDetail` onto `routeStack`.
|
|
- `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);
|
|
`RepositoryBrowseUseCasesTest.kt` gained coverage for `GetLatestPushEpochMillisUseCase` (max
|
|
across paginated branches, branches with no commit time ignored, no-branches case) and
|
|
`ListFavoritedRepositoriesUseCase` (pagination to completion, `defaultBranch` preserved).
|
|
- `./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. Live-observed separately: `adb shell am start` with the exact three extras
|
|
`FavoritePushNotifier` would set opened the app directly at
|
|
`RobotAutomationDevelopment-Dept/LGES-OC5-PtoP-Welder`'s file browser on branch `main`,
|
|
screenshot-verified. Not yet live-observed: the worker actually firing a notification
|
|
organically, 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
|
|
|
|
- The deep link targets the repository's default branch, not necessarily the specific branch that
|
|
was actually pushed to (see Design notes) — the detection step doesn't track which branch
|
|
produced the latest push time, only the timestamp itself. A repository with no default branch
|
|
still gets a notification, just without deep-link extras.
|
|
- 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 — no Codex review pending (Codex retired, 2026-09-29).
|