TASK-036 follow-up: deep-link the favorite-push notification
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>
This commit is contained in:
parent
b1a00233b0
commit
18e59d960c
@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
@ -62,6 +63,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val notificationPermissionLauncher =
|
private val notificationPermissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result observed indirectly via FavoritePushNotifier's own permission check at post-time */ }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@ -84,6 +91,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
|
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
|
||||||
val currentRoute = routeStack.last()
|
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) {
|
LaunchedEffect(currentRoute) {
|
||||||
when (val route = currentRoute) {
|
when (val route = currentRoute) {
|
||||||
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
|
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
|
||||||
@ -216,12 +233,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
consumeCallback(intent)
|
consumeCallback(intent)
|
||||||
|
consumeFavoritePushTarget(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
consumeCallback(intent)
|
consumeCallback(intent)
|
||||||
|
consumeFavoritePushTarget(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth's authorization/consent step must run in a real browser context, never an in-app
|
// 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))
|
setIntent(Intent(intent).setData(null))
|
||||||
viewModel.handleCallback(callback)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
|||||||
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||||
import com.ubisam.ubgitea.domain.usecase.GetLatestPushEpochMillisUseCase
|
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
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,22 +34,25 @@ class FavoritePushCheckWorker(
|
|||||||
AuthSession.tokenProvider(application),
|
AuthSession.tokenProvider(application),
|
||||||
)
|
)
|
||||||
val getLatestPush = GetLatestPushEpochMillisUseCase(browser)
|
val getLatestPush = GetLatestPushEpochMillisUseCase(browser)
|
||||||
val listFavorites = ListFavoritedRepositoryCoordinatesUseCase(browser)
|
val listFavorites = ListFavoritedRepositoriesUseCase(browser)
|
||||||
val store: LastSeenPushStore = SharedPreferencesLastSeenPushStore(application)
|
val store: LastSeenPushStore = SharedPreferencesLastSeenPushStore(application)
|
||||||
val notifier = FavoritePushNotifier(application)
|
val notifier = FavoritePushNotifier(application)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val favorites = listFavorites()
|
val favorites = listFavorites()
|
||||||
if (favorites.isEmpty()) return Result.success()
|
if (favorites.isEmpty()) return Result.success()
|
||||||
|
val defaultBranchByCoordinates = favorites.associate { it.coordinates to it.defaultBranch }
|
||||||
|
|
||||||
val current = favorites.mapNotNull { coordinates ->
|
val current = favorites.mapNotNull { repository ->
|
||||||
getLatestPush(coordinates)?.let { coordinates to it }
|
getLatestPush(repository.coordinates)?.let { repository.coordinates to it }
|
||||||
}.toMap()
|
}.toMap()
|
||||||
|
|
||||||
val newlyPushed = FavoritePushDetector().detectNewPushes(current, store.read())
|
val newlyPushed = FavoritePushDetector().detectNewPushes(current, store.read())
|
||||||
if (newlyPushed.isNotEmpty()) {
|
if (newlyPushed.isNotEmpty()) {
|
||||||
notifier.ensureChannel()
|
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
|
// 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
|
// repository dropped from favorites since the last run has its baseline cleared here
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import com.ubisam.ubgitea.MainActivity
|
import com.ubisam.ubgitea.MainActivity
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
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
|
* 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
|
* [FavoritePushCheckWorker] so posting a notification is a small, directly-inspectable seam rather
|
||||||
* than one more thing mixed into the worker's network/comparison logic.
|
* 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
|
* Deep-links to the repository's default branch when one is known ([defaultBranch] non-null) --
|
||||||
* notification opens the app at its normal entry point.
|
* 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) {
|
class FavoritePushNotifier(private val context: Context) {
|
||||||
fun ensureChannel() {
|
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
|
* the worker's own logic and its stored baseline update stay correct regardless -- the user
|
||||||
* just doesn't see the notification.
|
* 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) ==
|
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
if (!hasPermission) return
|
if (!hasPermission) return
|
||||||
|
|
||||||
val openApp = PendingIntent.getActivity(
|
val target = Intent(context, MainActivity::class.java)
|
||||||
context,
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
repository.hashCode(),
|
if (defaultBranch != null) {
|
||||||
Intent(context, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
target.putExtra(EXTRA_OWNER, repository.owner)
|
||||||
PendingIntent.FLAG_IMMUTABLE,
|
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)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_notification_push)
|
.setSmallIcon(R.drawable.ic_notification_push)
|
||||||
.setContentTitle(context.getString(R.string.favorite_push_notification_title, "${repository.owner}/${repository.name}"))
|
.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)
|
NotificationManagerCompat.from(context).notify(repository.hashCode(), notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "favorite_repo_push"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
private val favorites: RepositoryFavorites,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(): List<RepositoryCoordinates> {
|
suspend operator fun invoke(): List<RepositorySummary> {
|
||||||
val coordinates = mutableListOf<RepositoryCoordinates>()
|
val repositories = mutableListOf<RepositorySummary>()
|
||||||
var page = 1
|
var page = 1
|
||||||
repeat(MAX_PAGES) {
|
repeat(MAX_PAGES) {
|
||||||
val response = favorites.listStarred(page, PAGE_SIZE)
|
val response = favorites.listStarred(page, PAGE_SIZE)
|
||||||
coordinates += response.items.map { it.coordinates }
|
repositories += response.items
|
||||||
page = response.nextPage ?: return coordinates.toList()
|
page = response.nextPage ?: return repositories.toList()
|
||||||
}
|
}
|
||||||
return coordinates.toList()
|
return repositories.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@ -113,7 +113,7 @@ class RepositoryBrowseUseCasesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `listing favorited repository coordinates follows pagination to completion`() {
|
fun `listing favorited repositories follows pagination to completion`() {
|
||||||
val favorites = RecordingFavorites(
|
val favorites = RecordingFavorites(
|
||||||
listOf(
|
listOf(
|
||||||
Page.of(listOf(summary("a"), summary("b")), nextPage = 2),
|
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(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
@ -129,8 +129,11 @@ class RepositoryBrowseUseCasesTest {
|
|||||||
RepositoryCoordinates.of("owner", "b"),
|
RepositoryCoordinates.of("owner", "b"),
|
||||||
RepositoryCoordinates.of("owner", "c"),
|
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?) =
|
private fun branch(name: String, commitAtEpochMillis: Long?) =
|
||||||
|
|||||||
@ -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.
|
- 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 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.
|
- 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<Route.BranchDetail?>` 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.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# TASK-036: Push notification when a favorited repository gets a new push
|
# 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
|
Owner: Claude
|
||||||
Reviewer: Codex
|
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
|
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
|
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
|
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
|
## Scope
|
||||||
|
|
||||||
- Included: `androidx.work:work-runtime-ktx` dependency; new `core/notifications/` package
|
- Included: `androidx.work:work-runtime-ktx` dependency; new `core/notifications/` package
|
||||||
(`FavoritePushDetector`, `LastSeenPushStore` + Android impl, `GetLatestPushEpochMillisUseCase`,
|
(`FavoritePushDetector`, `LastSeenPushStore` + Android impl, `GetLatestPushEpochMillisUseCase`,
|
||||||
`FavoritePushCheckWorker`, `FavoritePushNotifier`, `FavoritePushScheduler`); `POST_NOTIFICATIONS`
|
`ListFavoritedRepositoriesUseCase`, `FavoritePushCheckWorker`, `FavoritePushNotifier`,
|
||||||
manifest permission + runtime request wiring in `MainActivity`; a notification channel.
|
`FavoritePushScheduler`); `POST_NOTIFICATIONS` manifest permission + runtime request wiring in
|
||||||
- Excluded: No deep link from the notification into the specific repository — tapping it opens the
|
`MainActivity`; a notification channel; a deep link from the notification tap into the
|
||||||
app to its normal entry point, not that repository directly (see Design notes). No change to
|
repository's default branch via `MainActivity`'s existing `Route`/intent handling.
|
||||||
Gitea's own `/notifications` feed or the existing in-app Notifications screen — this is a
|
- Excluded: No change to Gitea's own `/notifications` feed or the existing in-app Notifications
|
||||||
separate, local-only notification channel. No UI to configure the check interval or disable this
|
screen — this is a separate, local-only notification channel. No UI to configure the check
|
||||||
per-repository — it's on for every favorited repository, or off entirely if the OS permission is
|
interval or disable this per-repository — it's on for every favorited repository, or off entirely
|
||||||
denied.
|
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
|
## 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/domain/usecase/RepositoryBrowseUseCases.kt`
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.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/core/notifications/`
|
||||||
|
- `app/src/test/java/com/ubisam/ubgitea/domain/usecase/`
|
||||||
- `tasks/036-favorite-repo-push-notifications.md`, `docs/development-log.md`
|
- `tasks/036-favorite-repo-push-notifications.md`, `docs/development-log.md`
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
@ -78,11 +82,25 @@ seen.
|
|||||||
an error anywhere in this feature — `NotificationManagerCompat.notify()` silently no-ops without
|
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 permission, so the worker still runs and updates its stored baseline correctly either way,
|
||||||
the user just doesn't see the notification.
|
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
|
- **Deep link targets the default branch, not necessarily the pushed one**: `FavoritePushDetector`
|
||||||
the specific repository that pushed. Building a genuine deep link would mean extending this app's
|
only tracks one scalar "latest push time" per repository, not which branch produced it, so there's
|
||||||
custom sealed `Route` navigation to accept an externally-supplied target and wiring that through
|
no specific branch name available at notify time to target exactly. `ListFavoritedRepositoriesUseCase`
|
||||||
`MainActivity`'s intent handling — a meaningful chunk of additional, separable work not folded
|
was changed to return full `RepositorySummary` values (not just coordinates) specifically so
|
||||||
into this task; noted as a natural follow-up rather than attempted here.
|
`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
|
## 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
|
- [x] The worker is a no-op (not a crash, not a spurious notification) when there's no stored
|
||||||
session or no favorited repositories.
|
session or no favorited repositories.
|
||||||
- [x] Build, unit tests, and lint pass.
|
- [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
|
- [x] Live-confirmed: the `POST_NOTIFICATIONS` runtime prompt actually appeared on the device on
|
||||||
first launch and was granted (verified after the fact via
|
first launch and was granted (verified after the fact via
|
||||||
`dumpsys package com.ubisam.ubgitea`, `granted=true`); app launch and the scheduling call
|
`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).
|
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
|
- [x] Live-confirmed: sending `MainActivity` an intent carrying the exact extras
|
||||||
a real push landing on a favorited repository and the 15-minute interval elapsing, which
|
`FavoritePushNotifier` would set (`adb shell am start` with the three
|
||||||
doesn't fit inside this same live-testing session.
|
`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
|
## Security and privacy
|
||||||
|
|
||||||
@ -136,33 +163,48 @@ seen.
|
|||||||
→ the list of repositories that count as newly pushed), no Android/network dependency.
|
→ the list of repositories that count as newly pushed), no Android/network dependency.
|
||||||
- `core/notifications/LastSeenPushStore.kt` (+ `SharedPreferencesLastSeenPushStore`): read/write a
|
- `core/notifications/LastSeenPushStore.kt` (+ `SharedPreferencesLastSeenPushStore`): read/write a
|
||||||
`Map<RepositoryCoordinates, Long>`, `SharedPreferences`-backed.
|
`Map<RepositoryCoordinates, Long>`, `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
|
- `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;
|
- `core/notifications/FavoritePushCheckWorker.kt`: `CoroutineWorker` wiring the above together;
|
||||||
no-ops cleanly on no session / no favorites / any thrown failure (retries transient failures via
|
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
|
- `core/notifications/FavoritePushScheduler.kt`: `enqueueUniquePeriodicWork` with a 15-minute
|
||||||
interval and a network constraint.
|
interval and a network constraint.
|
||||||
- `MainActivity.kt`: schedules the periodic work on launch; requests `POST_NOTIFICATIONS` at
|
- `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.
|
- `AndroidManifest.xml`: `POST_NOTIFICATIONS` permission declared.
|
||||||
- New tests: `FavoritePushDetectorTest.kt` (first-sight-never-notifies, an actual advance notifies,
|
- 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 compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
|
||||||
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing.
|
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing.
|
||||||
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
|
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
|
||||||
- Installed on the human product owner's device over wireless `adb`. Live-observed: app launch
|
- 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
|
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
|
`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
|
or scheduling. Live-observed separately: `adb shell am start` with the exact three extras
|
||||||
requires a real push landing on a favorited repository and the 15-minute interval elapsing, which
|
`FavoritePushNotifier` would set opened the app directly at
|
||||||
doesn't fit inside this same live-testing session.
|
`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
|
## Known limitations
|
||||||
|
|
||||||
- No deep link from the notification to the specific repository (see Design notes) — tapping it
|
- The deep link targets the repository's default branch, not necessarily the specific branch that
|
||||||
just opens the app normally.
|
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
|
- 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.
|
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
|
- If the human product owner un-favorites a repository, its stored baseline is dropped on the very
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user