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