TASK-036: push notification when a favorited repository gets a new push

Gitea's own notification feed doesn't fire for plain pushes (only
issue/PR activity), so this app now periodically checks each favorited
repository's most recent push time itself and posts a local
notification when it advances past what was last seen.

- FavoritePushDetector: pure comparison logic, never notifies on a
  repository's first sighting (only a real advance counts), avoiding a
  notification storm the first time this feature ever runs.
- GetLatestPushEpochMillisUseCase: extracts the same 'newest branch
  commit time' heuristic RepositoryListViewModel already uses for the
  repository list's own last-push row, as a shared, reusable use case.
- SharedPreferencesLastSeenPushStore: one long per favorited repo, not
  Room (docs/architecture.md reserves Room for offline caching).
- FavoritePushCheckWorker: 15-minute WorkManager periodic job,
  network-constrained, no-ops cleanly with no session or no favorites.
- POST_NOTIFICATIONS requested at runtime on API 33+; a denial
  degrades safely, the worker still runs and updates its baseline.

No deep link from the notification to the specific repository yet --
tapping it opens the app normally. End-to-end delivery (a real push
landing, 15 minutes elapsing) is unit-tested and the scheduling/
permission wiring is live-confirmed (prompt appeared, was granted, no
crash), but an actual notification firing hasn't been observed within
this same live-testing session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-29 17:15:35 +09:00
parent 9e4cbe3e89
commit b1a00233b0
16 changed files with 699 additions and 0 deletions

View File

@ -76,9 +76,11 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.gson) implementation(libs.gson)
implementation(libs.androidx.work.runtime.ktx)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.androidx.work.testing)
} }

View File

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- TASK-036: favorited-repository push checker's local notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="false" android:allowBackup="false"

View File

@ -1,12 +1,15 @@
package com.ubisam.ubgitea package com.ubisam.ubgitea
import android.Manifest
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -15,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.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
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
@ -52,9 +56,19 @@ class MainActivity : ComponentActivity() {
NotificationsViewModel.Factory(application) NotificationsViewModel.Factory(application)
} }
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
// whether to actually show a prompt, this call is safe to make on every launch. Must be
// registered before STARTED (a property initializer, not inside onCreate's body after super).
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result observed indirectly via FavoritePushNotifier's own permission check at post-time */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
FavoritePushScheduler.schedule(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
setContent { setContent {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val codeBrowserState by codeBrowserViewModel.state.collectAsState() val codeBrowserState by codeBrowserViewModel.state.collectAsState()

View File

@ -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()
}
}
}

View File

@ -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<RepositoryCoordinates, Long>,
lastSeen: Map<RepositoryCoordinates, Long>,
): List<RepositoryCoordinates> = current.filter { (coordinates, latestEpochMillis) ->
val previouslySeenEpochMillis = lastSeen[coordinates]
previouslySeenEpochMillis != null && latestEpochMillis > previouslySeenEpochMillis
}.keys.toList()
}

View File

@ -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"
}
}

View File

@ -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<FavoritePushCheckWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
}

View File

@ -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<RepositoryCoordinates, Long>
fun write(values: Map<RepositoryCoordinates, Long>)
}
/**
* `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<RepositoryCoordinates, Long> = 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<RepositoryCoordinates, Long>) {
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"
}
}

View File

@ -11,6 +11,7 @@ import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
class ListRepositoriesUseCase( class ListRepositoriesUseCase(
private val repositoryBrowser: GiteaRepositoryBrowser, private val repositoryBrowser: GiteaRepositoryBrowser,
@ -94,3 +95,57 @@ class LoadSourceFileUseCase(
return repositoryBrowser.getFile(repository, ref, path) 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<BranchSummary> {
val branches = mutableListOf<BranchSummary>()
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<RepositoryCoordinates> {
val coordinates = mutableListOf<RepositoryCoordinates>()
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
}
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Notification small icon for TASK-036's favorite-push checker. The system renders notification
small icons as a single-color silhouette from the alpha channel only, so the fill color here is
irrelevant, only the shape matters. An upward arrow reads as "push" in git's own vocabulary
(pushing commits up to a remote), distinct from any existing in-app iconography (this app has no
icon library, per CLAUDE.md's established convention, so this is the one vector asset that
exists purely because Android requires a real small-icon resource for a valid notification).
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FF000000"
android:pathData="M12,3 L19,10 L15,10 L15,21 L9,21 L9,10 L5,10 Z" />
</vector>

View File

@ -0,0 +1,6 @@
<resources>
<string name="favorite_push_channel_name">즐겨찾기 저장소 Push 알림</string>
<string name="favorite_push_channel_description">즐겨찾기한 저장소에 새로운 push가 있으면 알려드립니다.</string>
<string name="favorite_push_notification_title">%1$s에 새로운 push가 있습니다</string>
<string name="favorite_push_notification_content">저장소를 열어 확인해 보세요.</string>
</resources>

View File

@ -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<RepositoryCoordinates>(), 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<RepositoryCoordinates>(), 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<RepositoryCoordinates>(), 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)
}
}

View File

@ -12,6 +12,7 @@ import com.ubisam.ubgitea.domain.model.SourceFile
import com.ubisam.ubgitea.domain.model.TreeEntry import com.ubisam.ubgitea.domain.model.TreeEntry
import com.ubisam.ubgitea.domain.model.TagSummary import com.ubisam.ubgitea.domain.model.TagSummary
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
import com.ubisam.ubgitea.domain.repository.RepositoryFavorites
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.startCoroutine import kotlin.coroutines.startCoroutine
@ -79,6 +80,92 @@ class RepositoryBrowseUseCasesTest {
assertEquals(listOf("file:src/App.kt"), browser.calls) 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<BranchSummary>): List<Page<BranchSummary>> =
pages.mapIndexed { index, items -> Page.of(items, nextPage = (index + 2).takeIf { index < pages.size - 1 }) }
private class BranchPagedBrowser(private val pages: List<Page<BranchSummary>>) : GiteaRepositoryBrowser {
override suspend fun listRepositories(page: Int, pageSize: Int) = Page.of(emptyList<RepositorySummary>(), 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<TagSummary>(), null)
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int) =
Page.of(emptyList<TreeEntry>(), 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<CommitSummary>(), null)
}
private class RecordingFavorites(private val pages: List<Page<RepositorySummary>>) : 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 { private class RecordingBrowser : GiteaRepositoryBrowser {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()

View File

@ -618,3 +618,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Known limitations: None identified. - 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 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. - 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.

View File

@ -10,6 +10,7 @@ coroutines = "1.10.2"
okhttp = "5.4.0" okhttp = "5.4.0"
gson = "2.13.1" gson = "2.13.1"
browser = "1.8.0" browser = "1.8.0"
workManager = "2.11.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@ -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<RepositoryCoordinates, Long>`, `SharedPreferences`-backed.
- `RepositoryBrowseUseCases.kt`: new `GetLatestPushEpochMillisUseCase`.
- `core/notifications/FavoritePushNotifier.kt`: notification channel creation + posting, isolated
from the worker so it's a thin, directly-inspectable seam.
- `core/notifications/FavoritePushCheckWorker.kt`: `CoroutineWorker` wiring the above together;
no-ops cleanly on no session / no favorites / any thrown failure (retries transient failures via
`Result.retry()`, matching `WorkManager` convention).
- `core/notifications/FavoritePushScheduler.kt`: `enqueueUniquePeriodicWork` with a 15-minute
interval and a network constraint.
- `MainActivity.kt`: schedules the periodic work on launch; requests `POST_NOTIFICATIONS` at
runtime on API 33+.
- `AndroidManifest.xml`: `POST_NOTIFICATIONS` permission declared.
- New tests: `FavoritePushDetectorTest.kt` (first-sight-never-notifies, an actual advance notifies,
an unchanged or older timestamp doesn't, multiple repositories are evaluated independently).
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing.
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
- Installed on the human product owner's device over wireless `adb`. Live-observed: app launch
triggered the `POST_NOTIFICATIONS` runtime prompt, which was granted (confirmed via
`dumpsys package com.ubisam.ubgitea` showing `granted=true`); no crash in `logcat` around launch
or scheduling. Not yet live-observed: the worker actually firing a notification, since that
requires a real push landing on a favorited repository and the 15-minute interval elapsing, which
doesn't fit inside this same live-testing session.
## Known limitations
- No deep link from the notification to the specific repository (see Design notes) — tapping it
just opens the app normally.
- No in-app way to disable this per-repository or change the interval; it's implicitly on for every
favorited repository as long as the OS notification permission is granted.
- If the human product owner un-favorites a repository, its stored baseline is dropped on the very
next successful worker run (`store.write(current)` replaces the whole snapshot with exactly that
run's favorited set each time) — re-favoriting it later is treated as a first sighting again, not
a retroactive notification for whatever happened while it was un-favorited. Judged the safer,
simpler default over trying to preserve and reconcile a stale baseline across that gap.
- `androidx.work` merges its own manifest permissions into the final app (`FOREGROUND_SERVICE`,
`RECEIVE_BOOT_COMPLETED`) — both normal-level, granted automatically at install time, not
something this task requested or needs to request at runtime; noted here only so a manifest diff
doesn't look unexplained.
- Actual end-to-end delivery of a real notification hasn't been observed yet in this session (see
Acceptance criteria) — the worker's logic is unit-tested, and the scheduling/permission wiring is
confirmed working live (the runtime prompt appeared and was granted, no crash on launch), but a
real push landing, 15 minutes elapsing, and a notification actually appearing in the system tray
is still unconfirmed.
## Review findings
None yet — pending Codex/human review.