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:
parent
9e4cbe3e89
commit
b1a00233b0
@ -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)
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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
|
||||
android:allowBackup="false"
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
package com.ubisam.ubgitea
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -15,6 +18,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
|
||||
@ -52,9 +56,19 @@ class MainActivity : ComponentActivity() {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
FavoritePushScheduler.schedule(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
setContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
19
app/src/main/res/drawable/ic_notification_push.xml
Normal file
19
app/src/main/res/drawable/ic_notification_push.xml
Normal 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>
|
||||
6
app/src/main/res/values/strings_favorite_push.xml
Normal file
6
app/src/main/res/values/strings_favorite_push.xml
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<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 {
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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" }
|
||||
|
||||
185
tasks/036-favorite-repo-push-notifications.md
Normal file
185
tasks/036-favorite-repo-push-notifications.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user