choiminyeong b1a00233b0 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>
2026-08-29 17:15:35 +09:00

249 lines
13 KiB
Kotlin

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
import androidx.compose.runtime.collectAsState
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
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
import com.ubisam.ubgitea.feature.history.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.notifications.NotificationsScreen
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
import com.ubisam.ubgitea.ui.navigation.Route
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
class MainActivity : ComponentActivity() {
private val viewModel: RepositoryListViewModel by viewModels {
RepositoryListViewModel.Factory(application)
}
private val codeBrowserViewModel: CodeBrowserViewModel by viewModels {
CodeBrowserViewModel.Factory(application)
}
private val historyViewModel: HistoryViewModel by viewModels {
HistoryViewModel.Factory(application)
}
private val workItemsViewModel: WorkItemsViewModel by viewModels {
WorkItemsViewModel.Factory(application)
}
private val notificationsViewModel: NotificationsViewModel by viewModels {
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()
val historyState by historyViewModel.state.collectAsState()
val workItemsState by workItemsViewModel.state.collectAsState()
val notificationsState by notificationsViewModel.state.collectAsState()
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
mutableStateOf(listOf<Route>(Route.Repositories))
}
fun push(route: Route) { routeStack = routeStack + route }
fun popToRepositories() { routeStack = listOf(Route.Repositories) }
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
val currentRoute = routeStack.last()
LaunchedEffect(currentRoute) {
when (val route = currentRoute) {
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
is Route.BranchDetail -> {
val repository = RepositoryCoordinates.of(route.owner, route.name)
val ref = RepositoryRef.of(route.ref)
codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
historyViewModel.open(HistoryTarget(repository, ref))
}
is Route.WorkItems -> workItemsViewModel.open(
WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)),
)
Route.Notifications -> notificationsViewModel.open()
}
}
fun authenticationExpired() {
codeBrowserViewModel.close()
historyViewModel.close()
workItemsViewModel.close()
notificationsViewModel.close()
popToRepositories()
viewModel.logout()
}
UBGiteaTheme {
when (currentRoute) {
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onNotificationSelected = { notification ->
push(Route.WorkItems(notification.repositoryOwner, notification.repositoryName))
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onBack = {
notificationsViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
is Route.WorkItems -> WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onGoToPage = workItemsViewModel::goToPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
onBackToList = workItemsViewModel::backToList,
onBack = {
workItemsViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
is Route.BranchDetail -> BranchDetailScreen(
codeBrowserState = codeBrowserState,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onCodeBrowserBack = {
if (!codeBrowserViewModel.navigateUp()) {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
},
onNavigateImage = codeBrowserViewModel::navigateImage,
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
Route.Repositories -> RepositoryListScreen(
state = state,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = viewModel::logout,
onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
push(
Route.BranchDetail(
owner = repository.coordinates.owner,
name = repository.coordinates.name,
refKind = refKind.name,
ref = name,
),
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onViewNotifications = { push(Route.Notifications) },
)
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openAuthorizationTab)
}
}
}
consumeCallback(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeCallback(intent)
}
// OAuth's authorization/consent step must run in a real browser context, never an in-app
// WebView the host app controls (RFC 8252) — a WebView could read the Gitea login form's
// credentials directly. A Custom Tab is the standard middle ground: it's the actual system
// browser underneath (session cookies, saved passwords, the works), but it's presented as an
// overlay on top of this activity instead of switching away to a separate browser app/task,
// which is what the human product owner asked for ("앱 내부에서 처리").
private fun openAuthorizationTab(uri: String) {
try {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, Uri.parse(uri))
} catch (_: ActivityNotFoundException) {
viewModel.browserLaunchFailed()
}
}
private fun consumeCallback(intent: Intent?) {
val callback = intent?.dataString ?: return
setIntent(Intent(intent).setData(null))
viewModel.handleCallback(callback)
}
}