Tapping the notification now opens the app directly at that repository's file browser on its default branch, instead of just the app's normal entry point. The push-detection step only tracks one scalar 'latest push time' per repository, not which branch produced it, so the deep link targets the repository's default branch rather than the specific pushed branch -- still a correct, useful 'take me to this repository' action. ListFavoritedRepositoriesUseCase now returns full RepositorySummary (not just coordinates) so defaultBranch is available; MainActivity bridges the notification's PendingIntent extras into its existing Route/routeStack navigation via a mutableStateOf Activity property read by a LaunchedEffect, mirroring the existing OAuth-callback consumption pattern. Live-verified via a simulated notification-tap intent (adb shell am start with the exact extras FavoritePushNotifier sets): opened directly at the target repository's file browser on the requested branch, screenshot-confirmed, no crash. Co-Authored-By: Claude Sonnet 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 — pending Codex/human review.
|