choiminyeong 69556b1e23 TASK-066: download page and in-app update check
- version 0.2.0 (versionCode 2)
- The app reads version.json from the download page once at launch and
  offers the page when a newer build is out; settings shows the version
  and a check button.
- tools/publish-download.py encrypts an APK under the download password
  and writes the page that decrypts it in the browser.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:52:45 +09:00

681 lines
37 KiB
Kotlin

package com.ubisam.ubgitea
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
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.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
import com.ubisam.ubgitea.core.settings.ThemeMode
import com.ubisam.ubgitea.core.update.AppUpdateLocations
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.res.stringResource
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.domain.notifications.NotificationSubjectType
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen
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.codebrowser.CodeViewerPreferences
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.feature.compare.CodeTabStrip
import com.ubisam.ubgitea.feature.compare.CodeWorkbench
import com.ubisam.ubgitea.feature.compare.CodeWorkbenchViewModel
import com.ubisam.ubgitea.feature.compare.CompareScreen
import com.ubisam.ubgitea.feature.compare.CompareUiState
import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
import com.ubisam.ubgitea.feature.compare.SavedCodeTab
import com.ubisam.ubgitea.feature.compare.WorkbenchMessage
import com.ubisam.ubgitea.feature.history.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.home.HomeDestination
import com.ubisam.ubgitea.feature.home.HomeScreen
import com.ubisam.ubgitea.feature.home.HomeViewModel
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.RepositoryListUiState
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.settings.SettingsScreen
import com.ubisam.ubgitea.feature.settings.SettingsViewModel
import com.ubisam.ubgitea.feature.work.WorkScreen
import com.ubisam.ubgitea.feature.work.WorkViewModel
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.shell.AppShell
import com.ubisam.ubgitea.ui.shell.AppTab
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)
}
private val settingsViewModel: SettingsViewModel by viewModels {
SettingsViewModel.Factory(application)
}
private val homeViewModel: HomeViewModel by viewModels {
HomeViewModel.Factory(application)
}
private val workViewModel: WorkViewModel by viewModels {
WorkViewModel.Factory(application)
}
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
CodeWorkbenchViewModel.Factory(application)
}
private val updateViewModel: AppUpdateViewModel by viewModels {
AppUpdateViewModel.Factory()
}
// 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 */ }
// TASK-036's notification deep link: set by consumeFavoritePushTarget from plain (non-Compose)
// Activity code (onCreate/onNewIntent), read by a LaunchedEffect inside setContent that pushes
// it onto routeStack once available -- a Compose MutableState works fine as a regular class
// property for exactly this reason, bridging Activity-lifecycle callbacks into composition.
private var pendingFavoritePushRoute: Route.BranchDetail? by mutableStateOf(null)
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()
val unreadNotifications by notificationsViewModel.unreadCount.collectAsState()
val settingsState by settingsViewModel.state.collectAsState()
val homeState by homeViewModel.state.collectAsState()
val workState by workViewModel.state.collectAsState()
val workbenchState by workbenchViewModel.state.collectAsState()
val updateState by updateViewModel.state.collectAsState()
// TASK-066: whether a newer build is published, once per launch.
LaunchedEffect(Unit) { updateViewModel.checkAtLaunch() }
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
mutableStateOf(listOf<Route>(Route.Home))
}
fun push(route: Route) { routeStack = routeStack + route }
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Home) } }
fun selectTab(tab: AppTab) { routeStack = listOf(tab.route) }
val currentRoute = routeStack.last()
val currentTab = routeStack.firstNotNullOfOrNull { it.tab } ?: AppTab.HOME
// The repository list's ViewModel owns sign-in, so its state says whether there is a
// session. While it is merely loading, whatever was true before still is.
var sessionReady by rememberSaveable { mutableStateOf(false) }
var accountName by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(state) {
state.sessionReadyOrNull()?.let { sessionReady = it }
if (state is RepositoryListUiState.SignedOut) accountName = null
state.accountNameOrNull()?.let { accountName = it }
}
// TASK-036: tapping a favorite-push notification. Pushed on top of whatever's
// currently showing rather than resetting the stack -- consistent with how every other
// in-app navigation here only ever pushes, never replaces.
LaunchedEffect(pendingFavoritePushRoute) {
pendingFavoritePushRoute?.let { route ->
push(route)
pendingFavoritePushRoute = null
}
}
// The route shown before this one: coming back from a comparison leaves the code as it was.
val lastRoute = remember { arrayOfNulls<Route>(1) }
LaunchedEffect(currentRoute, sessionReady) {
val previous = lastRoute[0]
lastRoute[0] = currentRoute
if (!sessionReady) return@LaunchedEffect
if (previous == Route.Compare && currentRoute is Route.BranchDetail) return@LaunchedEffect
if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount()
when (val route = currentRoute) {
Route.Home -> homeViewModel.open()
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
Route.Work -> workViewModel.open()
is Route.BranchDetail -> {
val repository = RepositoryCoordinates.of(route.owner, route.name)
val ref = RepositoryRef.of(route.ref)
val target = CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))
if (route.path != null) {
codeBrowserViewModel.openFile(target, RepositoryPath.of(route.path))
} else {
codeBrowserViewModel.open(target)
}
historyViewModel.open(HistoryTarget(repository, ref))
}
is Route.WorkItems -> {
val target = WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name))
if (route.number != null) {
workItemsViewModel.openItem(target, route.number, route.isPullRequest)
} else {
workItemsViewModel.open(target)
}
}
Route.Notifications -> notificationsViewModel.open()
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
Route.Compare -> Unit // CodeWorkbenchViewModel already holds what is compared.
}
}
// TASK-065: a comparison that has started is shown on its own screen; one that is gone
// (closed, or lost with the process) takes that screen away.
val comparing = workbenchState.compare != CompareUiState.None
LaunchedEffect(comparing) {
if (comparing && currentRoute != Route.Compare) push(Route.Compare)
}
LaunchedEffect(currentRoute, comparing) {
if (currentRoute == Route.Compare && !comparing) pop()
}
val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(workbenchState.message) {
val message = workbenchState.message ?: return@LaunchedEffect
val text = when (message) {
WorkbenchMessage.TABS_FULL -> context.getString(R.string.workbench_tabs_full, SavedCodeTab.MAX_TABS)
WorkbenchMessage.FILE_UNAVAILABLE -> context.getString(R.string.compare_file_unavailable)
WorkbenchMessage.FILE_NOT_TEXT -> context.getString(R.string.compare_file_not_text)
WorkbenchMessage.SAME_FILE -> context.getString(R.string.compare_same_file)
}
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
workbenchViewModel.messageShown()
}
fun closeFeatures() {
codeBrowserViewModel.close()
historyViewModel.close()
workItemsViewModel.close()
notificationsViewModel.close()
notificationsViewModel.clearUnreadCount()
homeViewModel.close()
workViewModel.close()
}
fun signOut() {
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
}
fun leaveBranchDetail() {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
val showNavigation = sessionReady && when (currentRoute) {
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
Route.Settings, Route.Compare -> false
else -> true
}
// A saved tab: the file in place when this screen already shows its repository and
// ref, otherwise this screen on that file in place of the code being read.
fun openTab(tab: SavedCodeTab) {
val route = currentRoute
val target = CodeBrowserTarget(
RepositoryCoordinates.of(tab.owner, tab.name),
RepositoryRef.of(tab.ref),
CodeReferenceKind.valueOf(tab.refKind),
)
if (route is Route.BranchDetail && route.owner == tab.owner && route.name == tab.name &&
route.refKind == tab.refKind && route.ref == tab.ref
) {
codeBrowserViewModel.openFile(target, RepositoryPath.of(tab.path))
return
}
val next = Route.BranchDetail(tab.owner, tab.name, tab.refKind, tab.ref, tab.path)
if (route is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
routeStack = routeStack.dropLast(1) + next
} else {
push(next)
}
}
val shownFile = codeBrowserState as? CodeBrowserUiState.File
val currentCodeTab = (currentRoute as? Route.BranchDetail)?.let { route ->
shownFile?.takeIf { it.target.repository == RepositoryCoordinates.of(route.owner, route.name) }?.let {
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
}
}
val workbench = if (sessionReady) {
CodeWorkbench(
tabs = workbenchState.tabs,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onToggleTab = workbenchViewModel::toggleTab,
onPin = workbenchViewModel::pin,
onCompareWith = workbenchViewModel::compareWith,
onClearPin = workbenchViewModel::clearPin,
onUseCommitFile = { commitId, shortId, path, side ->
(currentRoute as? Route.BranchDetail)?.let { route ->
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
}
},
)
} else {
null
}
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
val darkTheme = when (settingsState.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// The status and navigation bar icons have to contrast with the app's own theme, which
// the setting can make different from the system's.
DisposableEffect(darkTheme) {
val style = if (darkTheme) {
SystemBarStyle.dark(Color.TRANSPARENT)
} else {
SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
}
enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
onDispose {}
}
UBGiteaTheme(darkTheme = darkTheme) {
CompositionLocalProvider(LocalCodeWorkbench provides workbench) {
AppShell(
selectedTab = currentTab,
showNavigation = showNavigation,
unreadNotifications = unreadNotifications,
onTabSelected = { tab ->
// The Repositories tab pressed again steps back toward the list: from a
// repository's code to its branches' list page, from there to the first page.
val reselected = tab == AppTab.REPOSITORIES && currentTab == AppTab.REPOSITORIES
if (currentRoute is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
}
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
when {
!reselected -> selectTab(tab)
currentRoute == Route.Repositories -> viewModel.reselect()
else -> {
if (currentRoute == Route.Compare) workbenchViewModel.closeCompare()
selectTab(tab)
viewModel.backToRepositories()
}
}
},
strip = if (showStrip) {
{
CodeTabStrip(
tabs = workbenchState.tabs,
current = currentCodeTab,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onOpen = ::openTab,
onClose = workbenchViewModel::removeTab,
onClearPin = workbenchViewModel::clearPin,
)
}
} else {
null
},
) { isWide ->
// Back on any tab but Home goes Home; back on Home leaves the app.
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
selectTab(AppTab.HOME)
}
val repositoryList: @Composable () -> Unit = {
RepositoryListScreen(
state = state,
instanceHost = instanceHost,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = ::signOut,
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,
onViewSettings = { push(Route.Settings) },
)
}
when (val route = currentRoute.takeIf { sessionReady } ?: Route.Repositories) {
Route.Home -> HomeScreen(
state = homeState,
accountName = accountName,
instanceHost = instanceHost,
isWide = isWide,
onNavigate = { destination ->
when (destination) {
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
is HomeDestination.Work -> {
workViewModel.open(destination.scope, destination.type)
selectTab(AppTab.WORK)
}
is HomeDestination.Branch -> push(
Route.BranchDetail(
owner = destination.owner,
name = destination.name,
refKind = (if (destination.isTag) CodeReferenceKind.TAG else CodeReferenceKind.BRANCH).name,
ref = destination.ref,
),
)
is HomeDestination.WorkItem -> push(
Route.WorkItems(destination.owner, destination.name, destination.number, destination.isPullRequest),
)
}
},
onSelectSource = homeViewModel::selectSource,
onSelectFilter = homeViewModel::selectFilter,
onSetTodayOnly = homeViewModel::setTodayOnly,
onShowToday = homeViewModel::showToday,
onLoadMore = homeViewModel::loadMore,
onRefresh = homeViewModel::refresh,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
)
Route.Work -> WorkScreen(
state = workState,
accountName = accountName,
instanceHost = instanceHost,
onSelectScope = workViewModel::selectScope,
onSelectState = workViewModel::selectState,
onSelectType = workViewModel::selectType,
onItemSelected = { hit ->
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
},
onNextPage = workViewModel::nextPage,
onPreviousPage = workViewModel::previousPage,
onRefresh = workViewModel::refresh,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
)
Route.Settings -> SettingsScreen(
state = settingsState,
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
accountName = accountName,
instanceHost = instanceHost,
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
onSetThemeMode = settingsViewModel::setThemeMode,
onLogout = ::signOut,
onBack = { pop() },
update = updateState,
onCheckUpdate = updateViewModel::checkNow,
onOpenDownloadPage = ::openDownloadPage,
)
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onNotificationSelected = { notification ->
push(
Route.WorkItems(
owner = notification.repositoryOwner,
name = notification.repositoryName,
number = notification.subjectNumber,
isPullRequest = notification.subjectType == NotificationSubjectType.PULL_REQUEST,
),
)
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onBack = { selectTab(AppTab.HOME) },
onAuthenticationExpired = ::signOut,
accountName = accountName,
instanceHost = instanceHost,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
)
is Route.WorkItems -> {
val leave = {
workItemsViewModel.close()
pop()
}
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,
// An item opened directly came from a list elsewhere (a search, the
// timeline, a notification), so back returns there, not to this
// repository's own list.
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
onBack = leave,
onAuthenticationExpired = ::signOut,
)
}
is Route.BranchDetail -> BranchDetailScreen(
owner = route.owner,
name = route.name,
ref = route.ref,
codeBrowserState = codeBrowserState,
codeViewerPreferences = codeViewerPreferences,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
onNavigateImage = codeBrowserViewModel::navigateImage,
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
onOpenSibling = codeBrowserViewModel::openSibling,
onToggleFolder = codeBrowserViewModel::toggleFolder,
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = ::leaveBranchDetail,
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
onClose = ::leaveBranchDetail,
onAuthenticationExpired = ::signOut,
)
Route.Repositories -> repositoryList()
Route.Compare -> CompareScreen(
state = workbenchState.compare,
fontSizeSp = settingsState.codeFontSizeSp,
onSwap = workbenchViewModel::swapSides,
onClose = workbenchViewModel::closeCompare,
)
}
}
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openAuthorizationTab)
}
updateState.prompt?.let { release ->
AlertDialog(
onDismissRequest = updateViewModel::dismissPrompt,
title = { Text(stringResource(R.string.update_dialog_title)) },
text = {
val body = stringResource(R.string.update_dialog_body, updateState.installedVersionName, release.versionName)
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
},
confirmButton = {
TextButton(onClick = {
updateViewModel.dismissPrompt()
openDownloadPage()
}) { Text(stringResource(R.string.update_dialog_download)) }
},
dismissButton = {
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
},
)
}
}
}
consumeCallback(intent)
consumeFavoritePushTarget(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeCallback(intent)
consumeFavoritePushTarget(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()
}
}
// TASK-066: the download page asks for its password itself, so it opens in the browser.
private fun openDownloadPage() {
try {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD_PAGE))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this, AppUpdateLocations.DOWNLOAD_PAGE, Toast.LENGTH_LONG).show()
}
}
private fun consumeCallback(intent: Intent?) {
val callback = intent?.dataString ?: return
setIntent(Intent(intent).setData(null))
viewModel.handleCallback(callback)
}
// TASK-036: reads the extras FavoritePushNotifier's PendingIntent carries when a favorited
// repository's default branch was known at notify-time (it isn't always -- see
// FavoritePushNotifier's own doc comment), then clears them from the stored intent so rotating
// the device or otherwise recomposing doesn't re-navigate to the same target repeatedly.
private fun consumeFavoritePushTarget(intent: Intent?) {
val owner = intent?.getStringExtra(FavoritePushNotifier.EXTRA_OWNER) ?: return
val name = intent.getStringExtra(FavoritePushNotifier.EXTRA_NAME) ?: return
val ref = intent.getStringExtra(FavoritePushNotifier.EXTRA_REF) ?: return
intent.removeExtra(FavoritePushNotifier.EXTRA_OWNER)
intent.removeExtra(FavoritePushNotifier.EXTRA_NAME)
intent.removeExtra(FavoritePushNotifier.EXTRA_REF)
pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref)
}
}
private val AppTab.route: Route
get() = when (this) {
AppTab.HOME -> Route.Home
AppTab.REPOSITORIES -> Route.Repositories
AppTab.WORK -> Route.Work
AppTab.NOTIFICATIONS -> Route.Notifications
}
private val Route.tab: AppTab?
get() = when (this) {
Route.Home -> AppTab.HOME
Route.Repositories -> AppTab.REPOSITORIES
Route.Work -> AppTab.WORK
Route.Notifications -> AppTab.NOTIFICATIONS
else -> null
}
/** Whether there is a signed-in session, or null when this state does not say either way. */
private fun RepositoryListUiState.sessionReadyOrNull(): Boolean? = when (this) {
RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser -> false
RepositoryListUiState.Loading -> null
is RepositoryListUiState.Error -> canLogout
else -> true
}