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.provider.Settings 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.animation.AnimatedContent import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler import com.ubisam.ubgitea.core.settings.ThemeMode import com.ubisam.ubgitea.core.update.AppUpdateViewModel import com.ubisam.ubgitea.core.update.InstallStep import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryPath 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.CodeBrowserUiState 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.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.RepositoryListViewModel import com.ubisam.ubgitea.feature.repositories.accountNameOrNull 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.components.ExitConfirmDialog import com.ubisam.ubgitea.ui.components.NoResize import com.ubisam.ubgitea.ui.components.SplashOverlay import com.ubisam.ubgitea.ui.components.TabPager 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 allowInstalls = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (packageManager.canRequestPackageInstalls()) updateViewModel.installUpdate() } private val updateViewModel: AppUpdateViewModel by viewModels { AppUpdateViewModel.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 */ } // 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.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(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(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() } var confirmExit by remember { mutableStateOf(false) } 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 asks before leaving the // app, since it is easily pressed once too often. BackHandler(enabled = sessionReady && currentRoute.isTab) { if (currentRoute == Route.Home) confirmExit = true else 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) }, ) } // Entering a screen slides it in from the side it was opened towards. Keyed by // the kind of screen, so moving between code tabs or pages of one screen does // not replay it. The four tabs are one kind: between them the pager below glides. val stackDepth = routeStack.size val shownDepth = remember { intArrayOf(stackDepth) } val shownReady = remember { booleanArrayOf(sessionReady) } SideEffect { shownDepth[0] = stackDepth shownReady[0] = sessionReady } AnimatedContent( targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories, modifier = Modifier.fillMaxSize(), transitionSpec = { if (!shownReady[0]) { // The session has just been found: the first screen takes the // waiting one's place where it stands. Nothing was navigated, so // nothing slides. fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize) } else { val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1) (fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction }) .togetherWith(fadeOut(tween(90))) .using(NoResize) } }, contentKey = { if (it.isTab) AppTab::class else it::class }, label = "route", ) { shown -> val screen: @Composable (Route) -> Unit = { route -> Box(Modifier.fillMaxSize()) { when (route) { 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, onInstallUpdate = ::startUpdate, ) 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, ) } } } // Tabs sit side by side: going from one to another passes the ones between. val shownTab = shown.tab if (shownTab != null && sessionReady) { TabPager(AppTab.entries, shownTab) { screen(it.route) } } else { screen(shown) } } } } 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 = ::startUpdate) { Text(stringResource(R.string.update_dialog_download)) } }, dismissButton = { TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) } }, ) } when (updateState.install) { InstallStep.IDLE -> Unit InstallStep.DOWNLOADING, InstallStep.INSTALLING -> { val downloading = updateState.install == InstallStep.DOWNLOADING AlertDialog( // Only its button closes it: a stray tap should not drop a download. onDismissRequest = {}, title = { Text(stringResource(R.string.update_progress_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { if (downloading) { val fraction = updateState.downloaded Text(stringResource(R.string.update_progress_downloading, (fraction * 100).toInt())) LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth()) } else { Text(stringResource(R.string.update_progress_installing)) LinearProgressIndicator(Modifier.fillMaxWidth()) } } }, confirmButton = { TextButton(onClick = updateViewModel::cancelInstall) { Text(stringResource(if (downloading) R.string.update_progress_cancel else R.string.update_install_close)) } }, ) } InstallStep.FAILED -> AlertDialog( onDismissRequest = updateViewModel::cancelInstall, title = { Text(stringResource(R.string.update_install_failed_title)) }, text = { Text(stringResource(R.string.update_install_failed_body)) }, confirmButton = { TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_install_retry)) } }, dismissButton = { TextButton(onClick = updateViewModel::cancelInstall) { Text(stringResource(R.string.update_install_close)) } }, ) } if (confirmExit) { ExitConfirmDialog(onStay = { confirmExit = false }, onExit = { finish() }) } SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null) } } 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-073: the app fetches and installs the new build itself. Android asks once, in its own // settings, whether this app may install apps; the update starts when the person comes back. private fun startUpdate() { if (packageManager.canRequestPackageInstalls()) { updateViewModel.installUpdate() return } updateViewModel.dismissPrompt() Toast.makeText(this, R.string.update_allow_installs, Toast.LENGTH_LONG).show() try { allowInstalls.launch(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))) } catch (_: ActivityNotFoundException) { // No such settings screen on this device: nothing more the app can do. } } 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 }