From b1a00233b0f11fd055e3648ab3149e01c4ed2043 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Sat, 29 Aug 2026 17:15:35 +0900 Subject: [PATCH] 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 --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 2 + .../java/com/ubisam/ubgitea/MainActivity.kt | 14 ++ .../notifications/FavoritePushCheckWorker.kt | 69 +++++++ .../notifications/FavoritePushDetector.kt | 29 +++ .../notifications/FavoritePushNotifier.kt | 71 +++++++ .../notifications/FavoritePushScheduler.kt | 30 +++ .../core/notifications/LastSeenPushStore.kt | 51 +++++ .../usecase/RepositoryBrowseUseCases.kt | 55 ++++++ .../res/drawable/ic_notification_push.xml | 19 ++ .../main/res/values/strings_favorite_push.xml | 6 + .../notifications/FavoritePushDetectorTest.kt | 63 ++++++ .../usecase/RepositoryBrowseUseCasesTest.kt | 87 ++++++++ docs/development-log.md | 13 ++ gradle/libs.versions.toml | 3 + tasks/036-favorite-repo-push-notifications.md | 185 ++++++++++++++++++ 16 files changed, 699 insertions(+) create mode 100644 app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushCheckWorker.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetector.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushNotifier.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushScheduler.kt create mode 100644 app/src/main/java/com/ubisam/ubgitea/core/notifications/LastSeenPushStore.kt create mode 100644 app/src/main/res/drawable/ic_notification_push.xml create mode 100644 app/src/main/res/values/strings_favorite_push.xml create mode 100644 app/src/test/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetectorTest.kt create mode 100644 tasks/036-favorite-repo-push-notifications.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3da24f8..b40ff14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,9 +76,11 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.okhttp) implementation(libs.gson) + implementation(libs.androidx.work.runtime.ktx) debugImplementation(libs.androidx.compose.ui.tooling) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.androidx.work.testing) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2f6bd5..523a70a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + = Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } setContent { val state by viewModel.state.collectAsState() val codeBrowserState by codeBrowserViewModel.state.collectAsState() 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 new file mode 100644 index 0000000..c73e5af --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushCheckWorker.kt @@ -0,0 +1,69 @@ +package com.ubisam.ubgitea.core.notifications + +import android.app.Application +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.ubisam.ubgitea.core.auth.android.AuthSession +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 kotlinx.coroutines.CancellationException + +/** + * Periodic (see [FavoritePushScheduler] for the actual interval) check for every favorited + * repository's last-push time, per TASK-036. A no-op -- [Result.success], nothing posted -- when + * there's no stored session or no favorited repositories, so this safely does nothing before the + * human product owner has ever logged in or favorited anything, without needing the scheduler + * itself to track that state. + */ +class FavoritePushCheckWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val application = applicationContext as Application + val tokenVault = AuthSession.tokenVault(application) + if (tokenVault.read() == null) return Result.success() + + val browser = GiteaRepositoryBrowserApi( + UBGiteaOAuthDefaults.config, + OkHttpTransport(), + AuthSession.tokenProvider(application), + ) + val getLatestPush = GetLatestPushEpochMillisUseCase(browser) + val listFavorites = ListFavoritedRepositoryCoordinatesUseCase(browser) + val store: LastSeenPushStore = SharedPreferencesLastSeenPushStore(application) + val notifier = FavoritePushNotifier(application) + + return try { + val favorites = listFavorites() + if (favorites.isEmpty()) return Result.success() + + val current = favorites.mapNotNull { coordinates -> + getLatestPush(coordinates)?.let { coordinates to it } + }.toMap() + + val newlyPushed = FavoritePushDetector().detectNewPushes(current, store.read()) + if (newlyPushed.isNotEmpty()) { + notifier.ensureChannel() + newlyPushed.forEach(notifier::notifyNewPush) + } + // 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 + // rather than lingering forever; re-favoriting it later starts fresh (a first sighting, + // not a retroactive notification for whatever happened while it was un-favorited). + store.write(current) + Result.success() + } catch (failure: CancellationException) { + throw failure + } catch (_: Throwable) { + // Authentication expiry, a transient network hiccup, or a server error are all worth + // retrying on the next scheduled run rather than giving up on this feature entirely -- + // WorkManager's own backoff policy governs how soon that retry actually happens. + Result.retry() + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetector.kt b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetector.kt new file mode 100644 index 0000000..43952bf --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetector.kt @@ -0,0 +1,29 @@ +package com.ubisam.ubgitea.core.notifications + +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates + +/** + * Pure comparison between a freshly-fetched "latest push time per favorited repository" snapshot + * and whatever was last recorded, with no Android or network dependency so it's directly testable. + * See TASK-036 for why this exists at all -- Gitea's own notification feed doesn't fire for plain + * pushes, only issue/PR activity, so this app derives "did it just get pushed to" itself from the + * same signal [com.ubisam.ubgitea.domain.usecase.GetLatestPushEpochMillisUseCase] already uses for + * the repository list's own "마지막 push" row: a repository's newest branch commit time. + */ +class FavoritePushDetector { + /** + * A repository counts as newly pushed only when [lastSeen] already has a recorded value for it + * AND [current] is strictly greater. A repository [lastSeen] has never recorded is deliberately + * NOT reported here -- that's this repository's first sighting, establishing a baseline rather + * than a push worth notifying about. Without that distinction, the very first background check + * this feature ever runs would fire one notification per already-favorited repository + * regardless of whether anything had actually changed recently. + */ + fun detectNewPushes( + current: Map, + lastSeen: Map, + ): List = current.filter { (coordinates, latestEpochMillis) -> + val previouslySeenEpochMillis = lastSeen[coordinates] + previouslySeenEpochMillis != null && latestEpochMillis > previouslySeenEpochMillis + }.keys.toList() +} 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 new file mode 100644 index 0000000..b314ea4 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushNotifier.kt @@ -0,0 +1,71 @@ +package com.ubisam.ubgitea.core.notifications + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.ubisam.ubgitea.MainActivity +import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates + +/** + * Posts (and, on first use, creates the channel for) the local notification TASK-036's background + * checker fires when a favorited repository's last-push time advances. Kept separate from + * [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. + */ +class FavoritePushNotifier(private val context: Context) { + fun ensureChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.favorite_push_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = context.getString(R.string.favorite_push_channel_description) + } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + /** + * Silently does nothing if the app lacks POST_NOTIFICATIONS (denied or not yet granted on API + * 33+) -- checked explicitly (rather than letting [NotificationManagerCompat.notify] itself + * handle a missing permission) both to satisfy its `@RequiresPermission` lint contract and so + * this stays a deliberate no-op, not a caught exception. Below API 33 there's no such runtime + * permission to check at all, so this is unconditionally true on those versions. Either way, + * 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) { + 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 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}")) + .setContentText(context.getString(R.string.favorite_push_notification_content)) + .setAutoCancel(true) + .setContentIntent(openApp) + .build() + NotificationManagerCompat.from(context).notify(repository.hashCode(), notification) + } + + private companion object { + const val CHANNEL_ID = "favorite_repo_push" + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushScheduler.kt b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushScheduler.kt new file mode 100644 index 0000000..ae16e47 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/FavoritePushScheduler.kt @@ -0,0 +1,30 @@ +package com.ubisam.ubgitea.core.notifications + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +/** + * Schedules [FavoritePushCheckWorker] to run every 15 minutes -- `WorkManager`'s own platform + * floor for periodic work ([androidx.work.PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS]), and + * the cadence already communicated to the human product owner when this feature's feasibility was + * first discussed (TASK-036). Safe to call on every app launch: `ExistingPeriodicWorkPolicy.KEEP` + * makes a call while the work is already scheduled a no-op, so this doesn't need its own "is the + * user logged in" gate -- the worker itself no-ops cleanly when there's no session or no + * favorites (see [FavoritePushCheckWorker]). + */ +object FavoritePushScheduler { + private const val UNIQUE_WORK_NAME = "favorite_push_check" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request) + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/notifications/LastSeenPushStore.kt b/app/src/main/java/com/ubisam/ubgitea/core/notifications/LastSeenPushStore.kt new file mode 100644 index 0000000..e5d8403 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/notifications/LastSeenPushStore.kt @@ -0,0 +1,51 @@ +package com.ubisam.ubgitea.core.notifications + +import android.content.Context +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates + +/** Persists the last-seen "latest push" epoch-millis timestamp per favorited repository. */ +interface LastSeenPushStore { + fun read(): Map + fun write(values: Map) +} + +/** + * `SharedPreferences`-backed, not Room -- this is one `long` per favorited repository (a + * coordinates string key, an epoch-millis value), well inside what `SharedPreferences` handles + * cleanly, and `docs/architecture.md` reserves Room specifically for offline caching, which this + * isn't. `owner/name` is a safe, unambiguous key: [RepositoryCoordinates.of] already rejects any + * component containing `/`. + */ +class SharedPreferencesLastSeenPushStore(context: Context) : LastSeenPushStore { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + override fun read(): Map = preferences.all + .mapNotNull { (key, value) -> + val coordinates = key.toCoordinatesOrNull() ?: return@mapNotNull null + val epochMillis = value as? Long ?: return@mapNotNull null + coordinates to epochMillis + } + .toMap() + + override fun write(values: Map) { + val editor = preferences.edit().clear() + values.forEach { (coordinates, epochMillis) -> editor.putLong(coordinates.toKey(), epochMillis) } + editor.apply() + } + + private fun RepositoryCoordinates.toKey(): String = "$owner/$name" + + private fun String.toCoordinatesOrNull(): RepositoryCoordinates? { + val separatorIndex = indexOf('/') + if (separatorIndex <= 0 || separatorIndex == lastIndex) return null + return try { + RepositoryCoordinates.of(substring(0, separatorIndex), substring(separatorIndex + 1)) + } catch (_: IllegalArgumentException) { + null + } + } + + private companion object { + const val PREFERENCES_NAME = "ubgitea_favorite_push_last_seen" + } +} 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 a447a82..3d69f26 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 @@ -11,6 +11,7 @@ import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.domain.repository.RepositoryFavorites class ListRepositoriesUseCase( private val repositoryBrowser: GiteaRepositoryBrowser, @@ -94,3 +95,57 @@ class LoadSourceFileUseCase( return repositoryBrowser.getFile(repository, ref, path) } } + +/** + * All of a repository's favorited-status-independent branches, paginated to completion, reduced to + * the single timestamp this app treats as "when was this repository last pushed to": the newest + * branch commit time. Extracted as its own use case (TASK-036) specifically so the background + * favorite-push checker uses the exact same derivation `RepositoryListViewModel` already uses for + * the repository list's own "마지막 push" row, rather than a second, potentially-drifting copy of + * the same idea. + */ +class GetLatestPushEpochMillisUseCase( + private val repositoryBrowser: GiteaRepositoryBrowser, +) { + suspend operator fun invoke(repository: RepositoryCoordinates): Long? = + loadAllBranches(repository) + .mapNotNull { it.commitAtEpochMillis } + .maxOrNull() + + private suspend fun loadAllBranches(repository: RepositoryCoordinates): List { + val branches = mutableListOf() + var page = 1 + repeat(MAX_PAGES) { + val response = repositoryBrowser.listBranches(repository, page, PAGE_SIZE) + branches += response.items + page = response.nextPage ?: return branches.toList() + } + return branches.toList() + } + + private companion object { + const val PAGE_SIZE = 50 + const val MAX_PAGES = 100 + } +} + +/** Every currently-favorited repository's coordinates, paginated to completion. */ +class ListFavoritedRepositoryCoordinatesUseCase( + private val favorites: RepositoryFavorites, +) { + suspend operator fun invoke(): List { + val coordinates = 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() + } + return coordinates.toList() + } + + private companion object { + const val PAGE_SIZE = 50 + const val MAX_PAGES = 100 + } +} diff --git a/app/src/main/res/drawable/ic_notification_push.xml b/app/src/main/res/drawable/ic_notification_push.xml new file mode 100644 index 0000000..c54066c --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_push.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/values/strings_favorite_push.xml b/app/src/main/res/values/strings_favorite_push.xml new file mode 100644 index 0000000..bbb47a1 --- /dev/null +++ b/app/src/main/res/values/strings_favorite_push.xml @@ -0,0 +1,6 @@ + + 즐겨찾기 저장소 Push 알림 + 즐겨찾기한 저장소에 새로운 push가 있으면 알려드립니다. + %1$s에 새로운 push가 있습니다 + 저장소를 열어 확인해 보세요. + diff --git a/app/src/test/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetectorTest.kt b/app/src/test/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetectorTest.kt new file mode 100644 index 0000000..699777e --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/core/notifications/FavoritePushDetectorTest.kt @@ -0,0 +1,63 @@ +package com.ubisam.ubgitea.core.notifications + +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import org.junit.Assert.assertEquals +import org.junit.Test + +class FavoritePushDetectorTest { + private val detector = FavoritePushDetector() + private val repoA = RepositoryCoordinates.of("owner", "a") + private val repoB = RepositoryCoordinates.of("owner", "b") + + @Test + fun `a repository seen for the first time never counts as newly pushed`() { + val result = detector.detectNewPushes( + current = mapOf(repoA to 2_000L), + lastSeen = emptyMap(), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun `an advance past the previously-recorded time counts as newly pushed`() { + val result = detector.detectNewPushes( + current = mapOf(repoA to 2_000L), + lastSeen = mapOf(repoA to 1_000L), + ) + + assertEquals(listOf(repoA), result) + } + + @Test + fun `an unchanged time does not count as newly pushed`() { + val result = detector.detectNewPushes( + current = mapOf(repoA to 1_000L), + lastSeen = mapOf(repoA to 1_000L), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun `a time older than what was previously recorded does not count as newly pushed`() { + // Shouldn't happen in practice (push times only move forward), but the comparison itself + // must not treat a decrease as an advance. + val result = detector.detectNewPushes( + current = mapOf(repoA to 500L), + lastSeen = mapOf(repoA to 1_000L), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun `multiple repositories are evaluated independently`() { + val result = detector.detectNewPushes( + current = mapOf(repoA to 2_000L, repoB to 500L), + lastSeen = mapOf(repoA to 1_000L, repoB to 500L), + ) + + assertEquals(listOf(repoA), result) + } +} 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 4b8b158..c458981 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 @@ -12,6 +12,7 @@ import com.ubisam.ubgitea.domain.model.SourceFile import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.domain.repository.RepositoryFavorites import kotlin.coroutines.Continuation import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.startCoroutine @@ -79,6 +80,92 @@ class RepositoryBrowseUseCasesTest { assertEquals(listOf("file:src/App.kt"), browser.calls) } + @Test + fun `latest push epoch millis is the max commit time across all branch pages`() { + val branches = BranchPagedBrowser( + pagesOf( + listOf(branch("main", 1_000L), branch("dev", 3_000L)), + listOf(branch("release", 2_000L)), + ), + ) + + val result = runSuspend { GetLatestPushEpochMillisUseCase(branches)(coordinates) } + + assertEquals(3_000L, result) + } + + @Test + fun `latest push epoch millis ignores branches with no commit time`() { + val branches = BranchPagedBrowser(pagesOf(listOf(branch("main", null)))) + + val result = runSuspend { GetLatestPushEpochMillisUseCase(branches)(coordinates) } + + assertEquals(null, result) + } + + @Test + fun `latest push epoch millis is null for a repository with no branches`() { + val branches = BranchPagedBrowser(pagesOf(emptyList())) + + val result = runSuspend { GetLatestPushEpochMillisUseCase(branches)(coordinates) } + + assertEquals(null, result) + } + + @Test + fun `listing favorited repository coordinates follows pagination to completion`() { + val favorites = RecordingFavorites( + listOf( + Page.of(listOf(summary("a"), summary("b")), nextPage = 2), + Page.of(listOf(summary("c")), nextPage = null), + ), + ) + + val result = runSuspend { ListFavoritedRepositoryCoordinatesUseCase(favorites)() } + + assertEquals( + listOf( + RepositoryCoordinates.of("owner", "a"), + RepositoryCoordinates.of("owner", "b"), + RepositoryCoordinates.of("owner", "c"), + ), + result, + ) + } + + private fun branch(name: String, commitAtEpochMillis: Long?) = + BranchSummary(RepositoryRef.of(name), commitId = "sha-$name", isProtected = false, commitAtEpochMillis = commitAtEpochMillis) + + private fun summary(name: String) = RepositorySummary( + coordinates = RepositoryCoordinates.of("owner", name), + description = null, + defaultBranch = RepositoryRef.of("main"), + isPrivate = false, + isFork = false, + updatedAtEpochMillis = 0L, + ) + + private fun pagesOf(vararg pages: List): List> = + pages.mapIndexed { index, items -> Page.of(items, nextPage = (index + 2).takeIf { index < pages.size - 1 }) } + + private class BranchPagedBrowser(private val pages: List>) : GiteaRepositoryBrowser { + override suspend fun listRepositories(page: Int, pageSize: Int) = Page.of(emptyList(), null) + override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int) = pages[page - 1] + override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int) = Page.of(emptyList(), null) + override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int) = + Page.of(emptyList(), null) + override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath) = + SourceFile(path, ref, SourceContent.from(byteArrayOf()), "text/plain") + override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int) = + Page.of(emptyList(), null) + } + + private class RecordingFavorites(private val pages: List>) : RepositoryFavorites { + override suspend fun listStarred(page: Int, pageSize: Int) = pages[page - 1] + override suspend fun star(repository: RepositoryCoordinates) = Unit + override suspend fun unstar(repository: RepositoryCoordinates) = Unit + } + private class RecordingBrowser : GiteaRepositoryBrowser { val calls = mutableListOf() diff --git a/docs/development-log.md b/docs/development-log.md index da819f2..3db0e0b 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -618,3 +618,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: None identified. - Next recommended task: get live confirmation the header looks right without the button; otherwise continue per the standing direction on the rest of this session's queued items. - Next owner: Claude, continuing per the standing direction. + +## 2026-08-29 (continued) — TASK-036: push notification when a favorited repository gets a new push + +- Agent and role: Claude, direct continuation of the same live session — building the last remaining big feature from earlier this session's queue, confirmed with the human product owner before starting given it needed a new permission and dependency. +- Commit/diff: on `claude/task-015-notifications`; new `core/notifications/` package (`FavoritePushDetector`, `LastSeenPushStore` + `SharedPreferencesLastSeenPushStore`, `FavoritePushNotifier`, `FavoritePushCheckWorker`, `FavoritePushScheduler`), two new use cases in `RepositoryBrowseUseCases.kt` (`GetLatestPushEpochMillisUseCase`, `ListFavoritedRepositoryCoordinatesUseCase`), `MainActivity.kt` (scheduling + runtime permission request), `AndroidManifest.xml` (`POST_NOTIFICATIONS`), a new notification icon and string resources, `gradle/libs.versions.toml`/`app/build.gradle.kts` (`androidx.work:work-runtime-ktx` 2.11.2), matching tests, `tasks/036-favorite-repo-push-notifications.md`. +- Feature implemented: a 15-minute `WorkManager` periodic job (network-constrained) checks every favorited repository's most recent push time — reusing the exact same "newest branch commit time" heuristic `RepositoryListViewModel` already uses for the repository list's own "마지막 push" row, now extracted as a shared, reusable `GetLatestPushEpochMillisUseCase` — and posts a local notification when it advances past what was last recorded. +- How it was implemented: `FavoritePushDetector` is pure comparison logic (no Android/network dependency), deliberately never reporting a repository as "newly pushed" on its first sighting (only a real advance past a previously-recorded timestamp counts), which prevents the very first background check this feature ever runs from firing one notification per already-favorited repository regardless of actual recent activity. Storage is `SharedPreferences` (one long per favorited repository), not Room, since `docs/architecture.md` reserves Room specifically for offline caching. The worker is scheduled unconditionally on every app launch (`ExistingPeriodicWorkPolicy.KEEP` makes repeat calls a no-op) and instead gates internally — it just returns success immediately when there's no stored session or no favorited repositories, so the scheduler itself doesn't need to track login state. `POST_NOTIFICATIONS` is requested at runtime on API 33+ from `MainActivity`; a denial degrades safely (the worker still runs and updates its baseline, `NotificationManagerCompat.notify()` is explicitly permission-checked before being called at all, matching its `@RequiresPermission` lint contract as a deliberate no-op rather than a caught exception). +- Files changed: see `tasks/036-favorite-repo-push-notifications.md`'s Scope/Writable paths sections for the full list. +- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug` BUILD SUCCESSFUL, all unit tests passing (new coverage: `FavoritePushDetectorTest` — first-sight never notifies, an actual advance does, unchanged/older doesn't, multiple repositories evaluated independently; new `RepositoryBrowseUseCasesTest` coverage for both new use cases, including multi-page pagination and a repository with no branches/no commit time), 0 lint errors. Installed on the human product owner's device over wireless `adb`; live-confirmed the `POST_NOTIFICATIONS` runtime prompt actually appeared and was granted (verified via `dumpsys package`), and app launch/scheduling produced no crash in `logcat`. +- Review findings and resolutions: None from a separate review pass. +- 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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b52a6e..f3be04a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ coroutines = "1.10.2" okhttp = "5.4.0" gson = "2.13.1" browser = "1.8.0" +workManager = "2.11.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -29,6 +30,8 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/tasks/036-favorite-repo-push-notifications.md b/tasks/036-favorite-repo-push-notifications.md new file mode 100644 index 0000000..1aceb42 --- /dev/null +++ b/tasks/036-favorite-repo-push-notifications.md @@ -0,0 +1,185 @@ +# 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 + +- [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] 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 + +```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`, `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.