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