Ubisam-MobileGit/tasks/036-favorite-repo-push-notifications.md
choiminyeong b1a00233b0 TASK-036: push notification when a favorited repository gets a new push
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>
2026-08-29 17:15:35 +09:00

12 KiB

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

  • A periodic background check (15-minute WorkManager interval, network-constrained) compares each favorited repository's latest branch-commit time against what was last recorded.
  • 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.
  • A local notification is posted (title/body naming the repository) when a favorited repository's last-push time advances.
  • 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.
  • The worker is a no-op (not a crash, not a spurious notification) when there's no stored session or no favorited repositories.
  • Build, unit tests, and lint pass.
  • 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

./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.