package com.ubisam.ubgitea import android.Manifest import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryRef import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind import com.ubisam.ubgitea.feature.history.HistoryTarget import com.ubisam.ubgitea.feature.history.HistoryViewModel import com.ubisam.ubgitea.feature.notifications.NotificationsScreen import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel import com.ubisam.ubgitea.feature.repositories.ReferenceKind import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel import com.ubisam.ubgitea.ui.navigation.Route import com.ubisam.ubgitea.ui.navigation.RouteStackSaver import com.ubisam.ubgitea.ui.theme.UBGiteaTheme class MainActivity : ComponentActivity() { private val viewModel: RepositoryListViewModel by viewModels { RepositoryListViewModel.Factory(application) } private val codeBrowserViewModel: CodeBrowserViewModel by viewModels { CodeBrowserViewModel.Factory(application) } private val historyViewModel: HistoryViewModel by viewModels { HistoryViewModel.Factory(application) } private val workItemsViewModel: WorkItemsViewModel by viewModels { WorkItemsViewModel.Factory(application) } private val notificationsViewModel: NotificationsViewModel by viewModels { NotificationsViewModel.Factory(application) } // TASK-036: no-op if already granted or already permanently denied -- the OS itself decides // whether to actually show a prompt, this call is safe to make on every launch. Must be // registered before STARTED (a property initializer, not inside onCreate's body after super). private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result observed indirectly via FavoritePushNotifier's own permission check at post-time */ } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() FavoritePushScheduler.schedule(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } setContent { val state by viewModel.state.collectAsState() val codeBrowserState by codeBrowserViewModel.state.collectAsState() val historyState by historyViewModel.state.collectAsState() val workItemsState by workItemsViewModel.state.collectAsState() val notificationsState by notificationsViewModel.state.collectAsState() var routeStack by rememberSaveable(stateSaver = RouteStackSaver) { mutableStateOf(listOf(Route.Repositories)) } fun push(route: Route) { routeStack = routeStack + route } fun popToRepositories() { routeStack = listOf(Route.Repositories) } fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } } val currentRoute = routeStack.last() LaunchedEffect(currentRoute) { when (val route = currentRoute) { Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation. is Route.BranchDetail -> { val repository = RepositoryCoordinates.of(route.owner, route.name) val ref = RepositoryRef.of(route.ref) codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))) historyViewModel.open(HistoryTarget(repository, ref)) } is Route.WorkItems -> workItemsViewModel.open( WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)), ) Route.Notifications -> notificationsViewModel.open() } } fun authenticationExpired() { codeBrowserViewModel.close() historyViewModel.close() workItemsViewModel.close() notificationsViewModel.close() popToRepositories() viewModel.logout() } UBGiteaTheme { when (currentRoute) { Route.Notifications -> NotificationsScreen( state = notificationsState, onShowUnreadOnly = notificationsViewModel::showUnreadOnly, onShowAll = notificationsViewModel::showAll, onNotificationSelected = { notification -> push(Route.WorkItems(notification.repositoryOwner, notification.repositoryName)) }, onNextPage = notificationsViewModel::nextPage, onPreviousPage = notificationsViewModel::previousPage, onRetry = notificationsViewModel::retry, onBack = { notificationsViewModel.close() pop() }, onAuthenticationExpired = ::authenticationExpired, ) is Route.WorkItems -> WorkItemsScreen( state = workItemsState, onShowIssues = workItemsViewModel::showIssues, onShowPullRequests = workItemsViewModel::showPullRequests, onIssueSelected = workItemsViewModel::openIssue, onPullRequestSelected = workItemsViewModel::openPullRequest, onNextPage = workItemsViewModel::nextPage, onPreviousPage = workItemsViewModel::previousPage, onGoToPage = workItemsViewModel::goToPage, onRetryList = workItemsViewModel::retryList, onRetryDetail = workItemsViewModel::retryDetail, onBackToList = workItemsViewModel::backToList, onBack = { workItemsViewModel.close() pop() }, onAuthenticationExpired = ::authenticationExpired, ) is Route.BranchDetail -> BranchDetailScreen( codeBrowserState = codeBrowserState, onEntrySelected = codeBrowserViewModel::openEntry, onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, onRetryCodeBrowser = codeBrowserViewModel::retry, onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) { codeBrowserViewModel.close() historyViewModel.close() pop() } }, onNavigateImage = codeBrowserViewModel::navigateImage, historyState = historyState, onCommitSelected = historyViewModel::openCommit, onHistoryNextPage = historyViewModel::nextPage, onHistoryPreviousPage = historyViewModel::previousPage, onRetryHistoryList = historyViewModel::retryList, onRetryHistoryDetail = historyViewModel::retryDetail, onRetryPatch = historyViewModel::retryPatch, onFileSelected = historyViewModel::openFileDiff, onClearFileSelection = historyViewModel::clearFileSelection, onHistoryBackToList = historyViewModel::backToList, onHistoryBack = { codeBrowserViewModel.close() historyViewModel.close() pop() }, onAuthenticationExpired = ::authenticationExpired, ) Route.Repositories -> RepositoryListScreen( state = state, onSignIn = viewModel::signIn, onCancelSignIn = viewModel::cancelSignIn, onRetry = viewModel::retry, onLogout = viewModel::logout, onRepositorySelected = viewModel::selectRepository, onReferenceSelected = { repository, name, kind -> viewModel.selectReference(name, kind) val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG push( Route.BranchDetail( owner = repository.coordinates.owner, name = repository.coordinates.name, refKind = refKind.name, ref = name, ), ) }, onReferencesRefresh = viewModel::refreshReferences, onBackToRepositories = viewModel::backToRepositories, onNextPage = viewModel::nextPage, onPreviousPage = viewModel::previousPage, onRefreshRepositories = viewModel::refreshRepositories, onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch, onToggleFavorite = viewModel::toggleFavorite, onShowAllRepositories = viewModel::showAllRepositories, onShowFavorites = viewModel::showFavorites, onViewWorkItems = { repository -> push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name)) }, onSelectOrganization = viewModel::selectOrganization, onViewNotifications = { push(Route.Notifications) }, ) } LaunchedEffect(viewModel) { viewModel.browserRequests.collect(::openAuthorizationTab) } } } consumeCallback(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) consumeCallback(intent) } // OAuth's authorization/consent step must run in a real browser context, never an in-app // WebView the host app controls (RFC 8252) — a WebView could read the Gitea login form's // credentials directly. A Custom Tab is the standard middle ground: it's the actual system // browser underneath (session cookies, saved passwords, the works), but it's presented as an // overlay on top of this activity instead of switching away to a separate browser app/task, // which is what the human product owner asked for ("앱 내부에서 처리"). private fun openAuthorizationTab(uri: String) { try { CustomTabsIntent.Builder() .build() .launchUrl(this, Uri.parse(uri)) } catch (_: ActivityNotFoundException) { viewModel.browserLaunchFailed() } } private fun consumeCallback(intent: Intent?) { val callback = intent?.dataString ?: return setIntent(Intent(intent).setData(null)) viewModel.handleCallback(callback) } }