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>
249 lines
13 KiB
Kotlin
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)
|
|
}
|
|
}
|