- 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>
681 lines
37 KiB
Kotlin
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
|
|
}
|