diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index ffb2da4..8cf2605 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable +import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryRef @@ -62,6 +63,12 @@ class MainActivity : ComponentActivity() { private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result observed indirectly via FavoritePushNotifier's own permission check at post-time */ } + // TASK-036's notification deep link: set by consumeFavoritePushTarget from plain (non-Compose) + // Activity code (onCreate/onNewIntent), read by a LaunchedEffect inside setContent that pushes + // it onto routeStack once available -- a Compose MutableState works fine as a regular class + // property for exactly this reason, bridging Activity-lifecycle callbacks into composition. + private var pendingFavoritePushRoute: Route.BranchDetail? by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -84,6 +91,16 @@ class MainActivity : ComponentActivity() { fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } } val currentRoute = routeStack.last() + // TASK-036: tapping a favorite-push notification. Pushed on top of whatever's + // currently showing rather than resetting the stack -- consistent with how every other + // in-app navigation here only ever pushes, never replaces. + LaunchedEffect(pendingFavoritePushRoute) { + pendingFavoritePushRoute?.let { route -> + push(route) + pendingFavoritePushRoute = null + } + } + LaunchedEffect(currentRoute) { when (val route = currentRoute) { Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation. @@ -216,12 +233,14 @@ class MainActivity : ComponentActivity() { } } consumeCallback(intent) + consumeFavoritePushTarget(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) consumeCallback(intent) + consumeFavoritePushTarget(intent) } // OAuth's authorization/consent step must run in a real browser context, never an in-app @@ -245,4 +264,18 @@ class MainActivity : ComponentActivity() { setIntent(Intent(intent).setData(null)) viewModel.handleCallback(callback) } + + // TASK-036: reads the extras FavoritePushNotifier's PendingIntent carries when a favorited + // repository's default branch was known at notify-time (it isn't always -- see + // FavoritePushNotifier's own doc comment), then clears them from the stored intent so rotating + // the device or otherwise recomposing doesn't re-navigate to the same target repeatedly. + private fun consumeFavoritePushTarget(intent: Intent?) { + val owner = intent?.getStringExtra(FavoritePushNotifier.EXTRA_OWNER) ?: return + val name = intent.getStringExtra(FavoritePushNotifier.EXTRA_NAME) ?: return + val ref = intent.getStringExtra(FavoritePushNotifier.EXTRA_REF) ?: return + intent.removeExtra(FavoritePushNotifier.EXTRA_OWNER) + intent.removeExtra(FavoritePushNotifier.EXTRA_NAME) + intent.removeExtra(FavoritePushNotifier.EXTRA_REF) + pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref) + } } diff --git a/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushCheckWorker.kt b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushCheckWorker.kt index c73e5af..18f3934 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushCheckWorker.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushCheckWorker.kt @@ -9,7 +9,7 @@ import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults import com.ubisam.ubgitea.core.network.OkHttpTransport import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi import com.ubisam.ubgitea.domain.usecase.GetLatestPushEpochMillisUseCase -import com.ubisam.ubgitea.domain.usecase.ListFavoritedRepositoryCoordinatesUseCase +import com.ubisam.ubgitea.domain.usecase.ListFavoritedRepositoriesUseCase import kotlinx.coroutines.CancellationException /** @@ -34,22 +34,25 @@ class FavoritePushCheckWorker( AuthSession.tokenProvider(application), ) val getLatestPush = GetLatestPushEpochMillisUseCase(browser) - val listFavorites = ListFavoritedRepositoryCoordinatesUseCase(browser) + val listFavorites = ListFavoritedRepositoriesUseCase(browser) val store: LastSeenPushStore = SharedPreferencesLastSeenPushStore(application) val notifier = FavoritePushNotifier(application) return try { val favorites = listFavorites() if (favorites.isEmpty()) return Result.success() + val defaultBranchByCoordinates = favorites.associate { it.coordinates to it.defaultBranch } - val current = favorites.mapNotNull { coordinates -> - getLatestPush(coordinates)?.let { coordinates to it } + val current = favorites.mapNotNull { repository -> + getLatestPush(repository.coordinates)?.let { repository.coordinates to it } }.toMap() val newlyPushed = FavoritePushDetector().detectNewPushes(current, store.read()) if (newlyPushed.isNotEmpty()) { notifier.ensureChannel() - newlyPushed.forEach(notifier::notifyNewPush) + newlyPushed.forEach { coordinates -> + notifier.notifyNewPush(coordinates, defaultBranchByCoordinates[coordinates]) + } } // Replaces the whole stored snapshot with exactly this run's favorited set -- a // repository dropped from favorites since the last run has its baseline cleared here diff --git a/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushNotifier.kt b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushNotifier.kt index b314ea4..9c55725 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushNotifier.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushNotifier.kt @@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat import com.ubisam.ubgitea.MainActivity import com.ubisam.ubgitea.R import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositoryRef /** * Posts (and, on first use, creates the channel for) the local notification TASK-036's background @@ -20,8 +21,14 @@ import com.ubisam.ubgitea.domain.model.RepositoryCoordinates * [FavoritePushCheckWorker] so posting a notification is a small, directly-inspectable seam rather * than one more thing mixed into the worker's network/comparison logic. * - * No deep link into the specific repository yet (see TASK-036's Design notes) -- tapping the - * notification opens the app at its normal entry point. + * Deep-links to the repository's default branch when one is known ([defaultBranch] non-null) -- + * not necessarily the specific branch that was actually pushed to, since the detection step this + * worker uses only tracks one scalar "latest push time" per repository, not which branch produced + * it. Opening the 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. + * With no default branch (e.g. an empty repository), the notification still opens the app, just + * without navigating anywhere specific -- see [MainActivity]'s extras handling for the other half + * of this. */ class FavoritePushNotifier(private val context: Context) { fun ensureChannel() { @@ -44,17 +51,19 @@ class FavoritePushNotifier(private val context: Context) { * the worker's own logic and its stored baseline update stay correct regardless -- the user * just doesn't see the notification. */ - fun notifyNewPush(repository: RepositoryCoordinates) { + fun notifyNewPush(repository: RepositoryCoordinates, defaultBranch: RepositoryRef?) { val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED if (!hasPermission) return - val openApp = PendingIntent.getActivity( - context, - repository.hashCode(), - Intent(context, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), - PendingIntent.FLAG_IMMUTABLE, - ) + val target = Intent(context, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + if (defaultBranch != null) { + target.putExtra(EXTRA_OWNER, repository.owner) + target.putExtra(EXTRA_NAME, repository.name) + target.putExtra(EXTRA_REF, defaultBranch.value) + } + val openApp = PendingIntent.getActivity(context, repository.hashCode(), target, PendingIntent.FLAG_IMMUTABLE) val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification_push) .setContentTitle(context.getString(R.string.favorite_push_notification_title, "${repository.owner}/${repository.name}")) @@ -65,7 +74,13 @@ class FavoritePushNotifier(private val context: Context) { NotificationManagerCompat.from(context).notify(repository.hashCode(), notification) } - private companion object { - const val CHANNEL_ID = "favorite_repo_push" + companion object { + private const val CHANNEL_ID = "favorite_repo_push" + + // Public so MainActivity's intent-extras handling reads exactly the keys this class wrote, + // rather than duplicating the literal strings on both sides. + const val EXTRA_OWNER = "com.ubisam.ubgitea.EXTRA_FAVORITE_PUSH_OWNER" + const val EXTRA_NAME = "com.ubisam.ubgitea.EXTRA_FAVORITE_PUSH_NAME" + const val EXTRA_REF = "com.ubisam.ubgitea.EXTRA_FAVORITE_PUSH_REF" } } diff --git a/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt b/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt index 3d69f26..a2e5d00 100644 --- a/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt +++ b/app/src/main/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCases.kt @@ -129,19 +129,24 @@ class GetLatestPushEpochMillisUseCase( } } -/** Every currently-favorited repository's coordinates, paginated to completion. */ -class ListFavoritedRepositoryCoordinatesUseCase( +/** + * Every currently-favorited repository, paginated to completion. Returns the full + * [RepositorySummary] (not just coordinates) so callers -- currently just + * [com.ubisam.ubgitea.core.notifications.FavoritePushCheckWorker] -- have `defaultBranch` + * available too, e.g. to deep-link a push notification straight to that repository. + */ +class ListFavoritedRepositoriesUseCase( private val favorites: RepositoryFavorites, ) { - suspend operator fun invoke(): List { - val coordinates = mutableListOf() + suspend operator fun invoke(): List { + val repositories = mutableListOf() var page = 1 repeat(MAX_PAGES) { val response = favorites.listStarred(page, PAGE_SIZE) - coordinates += response.items.map { it.coordinates } - page = response.nextPage ?: return coordinates.toList() + repositories += response.items + page = response.nextPage ?: return repositories.toList() } - return coordinates.toList() + return repositories.toList() } private companion object { diff --git a/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt b/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt index c458981..206beb2 100644 --- a/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/domain/usecase/RepositoryBrowseUseCasesTest.kt @@ -113,7 +113,7 @@ class RepositoryBrowseUseCasesTest { } @Test - fun `listing favorited repository coordinates follows pagination to completion`() { + fun `listing favorited repositories follows pagination to completion`() { val favorites = RecordingFavorites( listOf( Page.of(listOf(summary("a"), summary("b")), nextPage = 2), @@ -121,7 +121,7 @@ class RepositoryBrowseUseCasesTest { ), ) - val result = runSuspend { ListFavoritedRepositoryCoordinatesUseCase(favorites)() } + val result = runSuspend { ListFavoritedRepositoriesUseCase(favorites)() } assertEquals( listOf( @@ -129,8 +129,11 @@ class RepositoryBrowseUseCasesTest { RepositoryCoordinates.of("owner", "b"), RepositoryCoordinates.of("owner", "c"), ), - result, + result.map { it.coordinates }, ) + // The full summary (not just coordinates) is returned so callers like the favorite-push + // checker have defaultBranch available too, e.g. to deep-link a notification. + assertEquals(RepositoryRef.of("main"), result.first().defaultBranch) } private fun branch(name: String, commitAtEpochMillis: Long?) = diff --git a/docs/development-log.md b/docs/development-log.md index 3db0e0b..dc5dbdf 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -631,3 +631,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: no deep link from the notification to the specific repository (opens the app at its normal entry point instead — a genuine follow-up-sized piece of work, not attempted here); no in-app way to disable this per-repository or change the interval; un-favoriting a repository drops its stored baseline on the next worker run, so re-favoriting it later starts fresh rather than retroactively notifying for what happened while un-favorited (a deliberate, simpler default). Actual end-to-end notification delivery (a real push landing, 15 minutes elapsing, a notification appearing) hasn't been observed yet in this session — that specifically doesn't fit inside one live-testing session's timeframe. - Next recommended task: wait for a real push to a favorited repository and confirm the notification actually appears; otherwise this closes out the last big item from this session's queue — remaining work is live-confirmation of everything already built (TASK-030 through TASK-035, plus pinch-zoom/CSV/PDF/Issues-PR pagination from earlier). - Next owner: Claude, continuing per the standing direction. + +## 2026-08-29 (continued) — TASK-036 follow-up: deep-link the favorite-push notification + +- Agent and role: Claude, direct continuation of the same live session — "계속 이어서 개발해줘 멈추지말고" (keep developing, don't stop) after TASK-036's initial commit. Picked up the deep-link follow-up TASK-036 itself had explicitly flagged as scoped out ("no deep link yet... noted as a natural follow-up"). +- Commit/diff: on `claude/task-015-notifications`; `RepositoryBrowseUseCases.kt` (`ListFavoritedRepositoryCoordinatesUseCase` → `ListFavoritedRepositoriesUseCase`, now returns full `RepositorySummary`), `FavoritePushNotifier.kt` (`notifyNewPush` takes an optional `defaultBranch`, attaches owner/name/ref string extras to the tap `PendingIntent`), `FavoritePushCheckWorker.kt` (looks up each repository's `defaultBranch` before notifying), `MainActivity.kt` (new `pendingFavoritePushRoute` Activity-property bridge + `consumeFavoritePushTarget`, wired into `onCreate`/`onNewIntent`), matching test updates, `tasks/036-favorite-repo-push-notifications.md` (updated throughout). +- Feature implemented: tapping the favorite-push 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. +- How it was implemented: since the underlying push-detection only tracks one scalar "latest push time" per repository (not which branch produced it), the deep link targets the repository's *default* branch rather than the specific pushed branch — a correct, useful "take me to this repository" action even though it isn't always literally the branch that changed. `MainActivity` already had a `consumeCallback` pattern (read-and-clear one-shot intent data, originally for the OAuth redirect) that a new `consumeFavoritePushTarget` mirrors exactly. Since `routeStack` is Compose state scoped inside `setContent`, not a plain Activity field, the bridge from plain Activity code (`onCreate`/`onNewIntent`) into composition is a `mutableStateOf` held as a regular Activity property, read by a `LaunchedEffect` inside `setContent` that pushes it onto `routeStack` once set. +- Files changed: see `tasks/036-favorite-repo-push-notifications.md`'s Scope/Writable paths sections. +- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug` BUILD SUCCESSFUL, all unit tests passing (new coverage: `GetLatestPushEpochMillisUseCase`'s max-across-pages/no-commit-time/no-branches cases; `ListFavoritedRepositoriesUseCase`'s pagination and `defaultBranch` preservation), 0 lint errors. Installed on the human product owner's device over wireless `adb`. +- Live verification: simulated a notification tap directly via `adb shell am start` with the exact three extras `FavoritePushNotifier` would set — the app opened directly at `RobotAutomationDevelopment-Dept/LGES-OC5-PtoP-Welder`'s file browser on branch `main`, screenshot-confirmed, no crash. This proves the deep-link *navigation* half works correctly; the actual trigger half (a real push firing the notification organically) still isn't observed within this session's timeframe. +- Review findings and resolutions: None from a separate review pass. +- Known limitations: deep link targets the default branch, not necessarily the specifically-pushed branch (by design, see above); a repository with no default branch still notifies, just without deep-link extras. +- Next recommended task: this closes out the last explicitly-scoped item from this session's feature queue. Remaining work is live-confirmation of everything already built across this whole session (TASK-027 through TASK-036) — pinch-zoom/CSV/PDF viewers, Issues/PR pagination, the green theme, and this notification feature's actual end-to-end firing. +- Next owner: Claude, continuing per the standing direction. diff --git a/tasks/036-favorite-repo-push-notifications.md b/tasks/036-favorite-repo-push-notifications.md index 1aceb42..e3fca2a 100644 --- a/tasks/036-favorite-repo-push-notifications.md +++ b/tasks/036-favorite-repo-push-notifications.md @@ -1,6 +1,6 @@ # 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 +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 @@ -14,20 +14,23 @@ issue/PR activity), so this can't be reached via the existing `/notifications` i 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. +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`, - `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. + `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 @@ -37,6 +40,7 @@ seen. - `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 @@ -78,11 +82,25 @@ seen. 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. +- **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` *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 @@ -98,13 +116,22 @@ seen. - [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). -- [ ] 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. +- [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 @@ -136,33 +163,48 @@ seen. → the list of repositories that count as newly pushed), no Android/network dependency. - `core/notifications/LastSeenPushStore.kt` (+ `SharedPreferencesLastSeenPushStore`): read/write a `Map`, `SharedPreferences`-backed. -- `RepositoryBrowseUseCases.kt`: new `GetLatestPushEpochMillisUseCase`. +- `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. + 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). + `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+. + 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). + 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. 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. + 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 -- No deep link from the notification to the specific repository (see Design notes) — tapping it - just opens the app normally. +- 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