diff --git a/app/src/main/java/com/ahmadkharfan/androidstudiolite/MainActivity.kt b/app/src/main/java/com/ahmadkharfan/androidstudiolite/MainActivity.kt index b9bde3b6..6e4dbd0a 100644 --- a/app/src/main/java/com/ahmadkharfan/androidstudiolite/MainActivity.kt +++ b/app/src/main/java/com/ahmadkharfan/androidstudiolite/MainActivity.kt @@ -1,5 +1,7 @@ package com.ahmadkharfan.androidstudiolite +import com.ahmadkharfan.androidstudiolite.feature.projects.navigation.ProjectsRoutes +import com.ahmadkharfan.androidstudiolite.feature.onboarding.navigation.OnboardingRoutes import android.annotation.SuppressLint import android.content.Context import android.content.Intent @@ -56,7 +58,7 @@ class MainActivity : ComponentActivity() { val routing = withContext(Dispatchers.IO) { val onboardingComplete = onboardingRepository.observeState().first().onboardingComplete if (!onboardingComplete) { - return@withContext Routes.ONBOARDING_WELCOME to null + return@withContext OnboardingRoutes.WELCOME to null } var projectId = openProjectId @@ -68,7 +70,7 @@ class MainActivity : ComponentActivity() { if (last != null) projectId = last.id } } - Routes.HUB to projectId + ProjectsRoutes.HUB to projectId } openProjectId = routing.second startDestination = routing.first diff --git a/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/NavGraphs.kt b/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/NavGraphs.kt index f772c262..300cdb59 100644 --- a/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/NavGraphs.kt +++ b/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/NavGraphs.kt @@ -11,7 +11,13 @@ import com.ahmadkharfan.androidstudiolite.feature.acsmissing.AcsMissingRoute import com.ahmadkharfan.androidstudiolite.feature.blockingerror.BlockingErrorRoute import com.ahmadkharfan.androidstudiolite.feature.blockingerror.BlockingErrorType import com.ahmadkharfan.androidstudiolite.feature.crashreport.CrashReportRoute -import com.ahmadkharfan.androidstudiolite.feature.createproject.CreateProjectRoute +import com.ahmadkharfan.androidstudiolite.feature.onboarding.navigation.OnboardingRoutes +import com.ahmadkharfan.androidstudiolite.feature.onboarding.navigation.onboardingGraph +import com.ahmadkharfan.androidstudiolite.feature.projects.navigation.ProjectsRoutes +import com.ahmadkharfan.androidstudiolite.feature.projects.navigation.projectsGraph +import com.ahmadkharfan.androidstudiolite.feature.settings.navigation.SettingsRoutes +import com.ahmadkharfan.androidstudiolite.feature.settings.navigation.settingsGraph +import com.ahmadkharfan.androidstudiolite.feature.terminal.navigation.terminalGraph import com.ahmadkharfan.androidstudiolite.feature.editor.EditorNavigation import com.ahmadkharfan.androidstudiolite.feature.editor.EditorRoute import com.ahmadkharfan.androidstudiolite.feature.editor.git.conflict.GitConflictRoute @@ -20,87 +26,36 @@ import com.ahmadkharfan.androidstudiolite.feature.editor.git.history.GitBlameRou import com.ahmadkharfan.androidstudiolite.feature.editor.git.history.GitHistoryRoute import com.ahmadkharfan.androidstudiolite.feature.editor.git.refs.GitRefsMode import com.ahmadkharfan.androidstudiolite.feature.editor.git.refs.GitRefsRoute -import com.ahmadkharfan.androidstudiolite.feature.folderpicker.FolderPickerRoute -import com.ahmadkharfan.androidstudiolite.feature.hub.HubRoute -import com.ahmadkharfan.androidstudiolite.feature.onboarding.complete.CompleteRoute -import com.ahmadkharfan.androidstudiolite.feature.onboarding.howitworks.HowItWorksRoute -import com.ahmadkharfan.androidstudiolite.feature.onboarding.permissions.PermissionsRoute -import com.ahmadkharfan.androidstudiolite.feature.onboarding.welcome.WelcomeRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.about.AboutRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.aiagent.AiAgentSettingsRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.buildrun.BuildRunSettingsRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.editor.EditorSettingsRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.general.GeneralRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.gitauth.GitAuthSettingsRoute -import com.ahmadkharfan.androidstudiolite.feature.settings.root.SettingsRootRoute -import com.ahmadkharfan.androidstudiolite.feature.terminal.TerminalRoute internal fun NavGraphBuilder.onboardingGraph(navController: NavHostController) { - composable(Routes.ONBOARDING_WELCOME) { - WelcomeRoute(onGetStarted = { navController.navigate(Routes.ONBOARDING_HOW_IT_WORKS) }) - } - - composable(Routes.ONBOARDING_HOW_IT_WORKS) { - HowItWorksRoute(onContinue = { navController.navigate(Routes.ONBOARDING_PERMISSIONS) }) - } - - composable(Routes.ONBOARDING_PERMISSIONS) { - PermissionsRoute(onContinue = { navController.navigate(Routes.ONBOARDING_COMPLETE) }) - } - composable(Routes.ONBOARDING_COMPLETE) { - CompleteRoute( - onOpenHub = { - navController.navigate(Routes.HUB) { popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true } } - }, - ) - } + onboardingGraph( + navigateTo = { route -> navController.navigate(route) }, + onFinished = { + navController.navigate(ProjectsRoutes.HUB) { + popUpTo(OnboardingRoutes.WELCOME) { inclusive = true } + } + }, + ) } internal fun NavGraphBuilder.projectsGraph(navController: NavHostController) { - composable(Routes.HUB) { backStackEntry -> - val pickedFolder by backStackEntry.savedStateHandle - .getStateFlow("picked_folder", null) - .collectAsState() - HubRoute( - onOpenProject = { id -> navController.navigate(Routes.editor(id)) }, - onCreateProject = { navController.navigate(Routes.CREATE_PROJECT) }, - onOpenPreferences = { navController.navigate(Routes.SETTINGS_ROOT) }, - onBrowseFolder = { navController.navigate(Routes.FOLDER_PICKER) }, - pickedFolder = pickedFolder, - onPickedFolderConsumed = { backStackEntry.savedStateHandle["picked_folder"] = null }, - ) - } - - composable(Routes.CREATE_PROJECT) { backStackEntry -> - val pickedFolder by backStackEntry.savedStateHandle - .getStateFlow("picked_folder", null) - .collectAsState() - CreateProjectRoute( - onBack = { navController.popBackStack(Routes.CREATE_PROJECT, inclusive = true) }, - onCreated = { id -> - navController.navigate(Routes.editor(id)) { popUpTo(Routes.HUB) } - }, - onBrowseLocation = { navController.navigate(Routes.FOLDER_PICKER) }, - pickedFolder = pickedFolder, - onPickedFolderConsumed = { backStackEntry.savedStateHandle["picked_folder"] = null }, - ) - } - - composable(Routes.FOLDER_PICKER) { - FolderPickerRoute( - onCancel = { navController.popBackStack() }, - onFolderSelected = { path -> - navController.previousBackStackEntry?.savedStateHandle?.set("picked_folder", path) - navController.popBackStack() - }, - ) - } + projectsGraph( + navigateTo = { route -> navController.navigate(route) }, + onOpenProject = { id -> navController.navigate(Routes.editor(id)) }, + onOpenPreferences = { navController.navigate(SettingsRoutes.ROOT) }, + onCreated = { id -> + navController.navigate(Routes.editor(id)) { popUpTo(ProjectsRoutes.HUB) } + }, + popBackTo = { route -> navController.popBackStack(route, inclusive = true) }, + popBack = { navController.popBackStack() }, + setPreviousResult = { key, value -> + navController.previousBackStackEntry?.savedStateHandle?.set(key, value) + }, + ) } internal fun NavGraphBuilder.utilityGraph(navController: NavHostController) { - composable(Routes.TERMINAL) { - TerminalRoute(onBack = { navController.popBackStack() }) - } + terminalGraph(onBack = { navController.popBackStack() }) composable(Routes.CRASH_REPORT) { CrashReportRoute( @@ -131,15 +86,15 @@ private fun NavGraphBuilder.editorDestination(navController: NavHostController) projectId = projectId, navigation = EditorNavigation( onCloseProject = { - if (!navController.popBackStack(Routes.HUB, inclusive = false)) { - navController.navigate(Routes.HUB) { + if (!navController.popBackStack(ProjectsRoutes.HUB, inclusive = false)) { + navController.navigate(ProjectsRoutes.HUB) { popUpTo(navController.graph.id) { inclusive = true } launchSingleTop = true } } }, - onOpenSettings = { navController.navigate(Routes.SETTINGS_ROOT) }, - onOpenAiAgentSettings = { navController.navigate(Routes.SETTINGS_AI_AGENT) }, + onOpenSettings = { navController.navigate(SettingsRoutes.ROOT) }, + onOpenAiAgentSettings = { navController.navigate(SettingsRoutes.AI_AGENT) }, onOpenGitDiff = { path, target -> navController.navigate(Routes.gitDiff(projectId, path, target)) }, onOpenGitHistory = { path -> navController.navigate(Routes.gitHistory(projectId, path)) }, onOpenGitBlame = { path -> navController.navigate(Routes.gitBlame(projectId, path)) }, @@ -248,35 +203,10 @@ private fun NavGraphBuilder.gitConflictsDestination(navController: NavHostContro } internal fun NavGraphBuilder.settingsGraph(navController: NavHostController) { - composable(Routes.SETTINGS_ROOT) { - SettingsRootRoute( - onBack = { navController.popBackStack() }, - onOpenGeneral = { navController.navigate(Routes.SETTINGS_GENERAL) }, - onOpenEditor = { navController.navigate(Routes.SETTINGS_EDITOR) }, - onOpenAiAgent = { navController.navigate(Routes.SETTINGS_AI_AGENT) }, - onOpenBuildRun = { navController.navigate(Routes.SETTINGS_BUILD_RUN) }, - onOpenGitAuth = { navController.navigate(Routes.SETTINGS_GIT_AUTH) }, - onOpenAbout = { navController.navigate(Routes.SETTINGS_ABOUT) }, - ) - } - composable(Routes.SETTINGS_GENERAL) { - GeneralRoute(onBack = { navController.popBackStack() }) - } - composable(Routes.SETTINGS_EDITOR) { - EditorSettingsRoute(onBack = { navController.popBackStack() }) - } - composable(Routes.SETTINGS_AI_AGENT) { - AiAgentSettingsRoute(onBack = { navController.popBackStack() }) - } - composable(Routes.SETTINGS_BUILD_RUN) { - BuildRunSettingsRoute(onBack = { navController.popBackStack() }) - } - composable(Routes.SETTINGS_GIT_AUTH) { - GitAuthSettingsRoute(onBack = { navController.popBackStack() }) - } - composable(Routes.SETTINGS_ABOUT) { - AboutRoute(onBack = { navController.popBackStack() }) - } + settingsGraph( + navigateTo = { route -> navController.navigate(route) }, + onBack = { navController.popBackStack() }, + ) } internal fun NavGraphBuilder.deviceSupportGraph() { diff --git a/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/Routes.kt b/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/Routes.kt index 3bfa67a2..6af2c526 100644 --- a/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/Routes.kt +++ b/app/src/main/java/com/ahmadkharfan/androidstudiolite/navigation/Routes.kt @@ -1,51 +1,36 @@ package com.ahmadkharfan.androidstudiolite.navigation -import android.net.Uri +import com.ahmadkharfan.androidstudiolite.core.navigation.encodeRouteArg import com.ahmadkharfan.androidstudiolite.domain.model.GitDiffTarget object Routes { - const val ONBOARDING_WELCOME = "onboarding/welcome" - const val ONBOARDING_HOW_IT_WORKS = "onboarding/how-it-works" - const val ONBOARDING_PERMISSIONS = "onboarding/permissions" - const val ONBOARDING_COMPLETE = "onboarding/complete" - - const val HUB = "hub" - const val CREATE_PROJECT = "createProject" const val EDITOR_PATTERN = "editor/{projectId}" - fun editor(projectId: String) = "editor/$projectId" + fun editor(projectId: String) = "editor/${encodeRouteArg(projectId)}" const val GIT_DIFF_PATTERN = "gitDiff/{projectId}/{target}?path={path}&commitId={commitId}" fun gitDiff(projectId: String, path: String, target: GitDiffTarget, commitId: String? = null) = - "gitDiff/${Uri.encode(projectId)}/${target.name}?path=${Uri.encode(path)}&commitId=${Uri.encode(commitId.orEmpty())}" + "gitDiff/${encodeRouteArg(projectId)}/${target.name}" + + "?path=${encodeRouteArg(path)}&commitId=${encodeRouteArg(commitId.orEmpty())}" const val GIT_HISTORY_PATTERN = "gitHistory/{projectId}?path={path}" fun gitHistory(projectId: String, path: String? = null) = - "gitHistory/${Uri.encode(projectId)}?path=${Uri.encode(path.orEmpty())}" + "gitHistory/${encodeRouteArg(projectId)}?path=${encodeRouteArg(path.orEmpty())}" const val GIT_BLAME_PATTERN = "gitBlame/{projectId}?path={path}" fun gitBlame(projectId: String, path: String) = - "gitBlame/${Uri.encode(projectId)}?path=${Uri.encode(path)}" + "gitBlame/${encodeRouteArg(projectId)}?path=${encodeRouteArg(path)}" const val GIT_REFS_PATTERN = "gitRefs/{projectId}/{mode}" - fun gitRefs(projectId: String, mode: String) = "gitRefs/${Uri.encode(projectId)}/$mode" + fun gitRefs(projectId: String, mode: String) = + "gitRefs/${encodeRouteArg(projectId)}/${encodeRouteArg(mode)}" const val GIT_CONFLICTS_PATTERN = "gitConflicts/{projectId}" - fun gitConflicts(projectId: String) = "gitConflicts/${Uri.encode(projectId)}" - - const val SETTINGS_ROOT = "settings/root" - const val SETTINGS_GENERAL = "settings/general" - const val SETTINGS_EDITOR = "settings/editor" - const val SETTINGS_AI_AGENT = "settings/aiAgent" - const val SETTINGS_BUILD_RUN = "settings/buildRun" - const val SETTINGS_GIT_AUTH = "settings/gitAuth" - const val SETTINGS_ABOUT = "settings/about" + fun gitConflicts(projectId: String) = "gitConflicts/${encodeRouteArg(projectId)}" - const val TERMINAL = "terminal" const val CRASH_REPORT = "crashReport" - const val FOLDER_PICKER = "folderPicker" const val ACS_MISSING = "acsMissing" const val BLOCKING_ERROR_PATTERN = "blockingError/{type}" - fun blockingError(type: String) = "blockingError/$type" + fun blockingError(type: String) = "blockingError/${encodeRouteArg(type)}" } diff --git a/app/src/test/java/com/ahmadkharfan/androidstudiolite/navigation/RoutesTest.kt b/app/src/test/java/com/ahmadkharfan/androidstudiolite/navigation/RoutesTest.kt new file mode 100644 index 00000000..0ff2ccf6 --- /dev/null +++ b/app/src/test/java/com/ahmadkharfan/androidstudiolite/navigation/RoutesTest.kt @@ -0,0 +1,114 @@ +package com.ahmadkharfan.androidstudiolite.navigation + +import com.ahmadkharfan.androidstudiolite.domain.model.GitDiffTarget +import java.io.ByteArrayOutputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Project ids come straight from directory names (`AndroidProjectRepository`, `id = dir.name`) with + * no character sanitisation, so they can contain `?`, `#` and `&` — all legal in a directory name + * and all structural in a route string. + */ +class RoutesTest { + + @Test + fun `editor escapes characters that would end the path segment`() { + assertEquals("editor/my%3Fapp", Routes.editor("my?app")) + assertEquals("editor/my%23app", Routes.editor("my#app")) + assertEquals("editor/a%26b", Routes.editor("a&b")) + } + + @Test + fun `editor round trips ids through the pattern`() { + for (projectId in HOSTILE_PROJECT_IDS) { + assertEquals( + "round trip failed for '$projectId'", + projectId, + matchPathArg(Routes.EDITOR_PATTERN, Routes.editor(projectId), "projectId"), + ) + } + } + + @Test + fun `editor leaves ordinary ids readable`() { + assertEquals("editor/MyApp-1.2_x~y", Routes.editor("MyApp-1.2_x~y")) + } + + @Test + fun `git routes round trip the same ids as editor`() { + for (projectId in HOSTILE_PROJECT_IDS) { + val routes = mapOf( + Routes.GIT_HISTORY_PATTERN to Routes.gitHistory(projectId), + Routes.GIT_BLAME_PATTERN to Routes.gitBlame(projectId, "src/Main.kt"), + Routes.GIT_REFS_PATTERN to Routes.gitRefs(projectId, "BRANCHES"), + Routes.GIT_CONFLICTS_PATTERN to Routes.gitConflicts(projectId), + Routes.GIT_DIFF_PATTERN to + Routes.gitDiff(projectId, "src/Main.kt", GitDiffTarget.INDEX_TO_WORKTREE), + ) + for ((pattern, route) in routes) { + assertEquals( + "round trip failed for '$projectId' on $pattern", + projectId, + matchPathArg(pattern, route, "projectId"), + ) + } + } + } + + @Test + fun `blockingError escapes its argument`() { + assertEquals("blockingError/sdcard", Routes.blockingError("sdcard")) + assertEquals("blockingError/a%3Fb", Routes.blockingError("a?b")) + } + + /** + * Mirrors how Navigation resolves a path argument: the route is cut at the first `?` or `#`, + * the remaining path is matched against the pattern with `{arg}` as `([^/]+?)`, and the matched + * group is percent-decoded (`NavDeepLink.getMatchingPathArguments` → `Uri.decode`). Returns null + * when the destination would not match at all. + */ + private fun matchPathArg(pattern: String, route: String, argName: String): String? { + val path = route.substringBefore('?').substringBefore('#') + val argNames = ARG_PLACEHOLDER.findAll(pattern.substringBefore('?')).map { it.groupValues[1] }.toList() + val regex = ARG_PLACEHOLDER.split(pattern.substringBefore('?')) + .joinToString(separator = "([^/]+?)") { Regex.escape(it) } + .toRegex() + val groups = regex.matchEntire(path)?.groupValues ?: return null + val index = argNames.indexOf(argName) + return percentDecode(groups[index + 1]) + } + + private fun percentDecode(value: String): String { + val bytes = ByteArrayOutputStream() + var index = 0 + while (index < value.length) { + val char = value[index] + if (char == '%') { + bytes.write(value.substring(index + 1, index + 3).toInt(radix = 16)) + index += 3 + } else { + bytes.write(char.code) + index++ + } + } + return bytes.toByteArray().toString(Charsets.UTF_8) + } + + private companion object { + val ARG_PLACEHOLDER = Regex("""\{(\w+)}""") + + val HOSTILE_PROJECT_IDS = listOf( + "my?app", + "my#app", + "a&b", + "a b", + "100%done", + "a=b", + "a/b", + "café ☕", + "plain", + ) + } +} diff --git a/core/common/src/main/java/com/ahmadkharfan/androidstudiolite/core/navigation/RouteArgs.kt b/core/common/src/main/java/com/ahmadkharfan/androidstudiolite/core/navigation/RouteArgs.kt new file mode 100644 index 00000000..da686c76 --- /dev/null +++ b/core/common/src/main/java/com/ahmadkharfan/androidstudiolite/core/navigation/RouteArgs.kt @@ -0,0 +1,43 @@ +package com.ahmadkharfan.androidstudiolite.core.navigation + +private const val UNRESERVED_PUNCTUATION = "-_.~" +private const val HEX_DIGITS = "0123456789ABCDEF" +private const val BYTE_MASK = 0xFF +private const val HIGH_NIBBLE_SHIFT = 4 +private const val LOW_NIBBLE_MASK = 0x0F + +/** + * Percent-encodes [value] so it can be embedded in a route without altering the route's structure. + * + * Project ids are directory names, which may legally contain `?`, `#` and `&`. Left raw, those turn + * a path segment into a query or fragment, so the destination either fails to match or receives a + * truncated argument. + * + * Everything outside the RFC 3986 unreserved set is escaped as UTF-8 percent triplets. Navigation + * runs matched path and query arguments through `Uri.decode` before handing them to a destination, + * which reverses this exactly — so read-back sites must not decode again. + * + * Deliberately pure Kotlin rather than `android.net.Uri.encode`: route building then stays testable + * in plain JVM unit tests, where `testOptions.unitTests.isReturnDefaultValues` makes framework calls + * return null. It escapes a slightly wider set than `Uri.encode` (which leaves `!'()*` alone); both + * decode back to the same string. + */ +fun encodeRouteArg(value: String): String { + if (value.all(::isUnreserved)) return value + return buildString(value.length) { + for (byte in value.toByteArray(Charsets.UTF_8)) { + val code = byte.toInt() and BYTE_MASK + val char = code.toChar() + if (isUnreserved(char)) { + append(char) + } else { + append('%') + append(HEX_DIGITS[code shr HIGH_NIBBLE_SHIFT]) + append(HEX_DIGITS[code and LOW_NIBBLE_MASK]) + } + } + } +} + +private fun isUnreserved(char: Char): Boolean = + char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char in UNRESERVED_PUNCTUATION diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index a3cfeb39..a090731b 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -10,4 +10,5 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) + implementation(libs.androidx.navigation.compose) } diff --git a/feature/onboarding/src/main/java/com/ahmadkharfan/androidstudiolite/feature/onboarding/navigation/OnboardingNavigation.kt b/feature/onboarding/src/main/java/com/ahmadkharfan/androidstudiolite/feature/onboarding/navigation/OnboardingNavigation.kt new file mode 100644 index 00000000..4346a8c6 --- /dev/null +++ b/feature/onboarding/src/main/java/com/ahmadkharfan/androidstudiolite/feature/onboarding/navigation/OnboardingNavigation.kt @@ -0,0 +1,30 @@ +package com.ahmadkharfan.androidstudiolite.feature.onboarding.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.ahmadkharfan.androidstudiolite.feature.onboarding.complete.CompleteRoute +import com.ahmadkharfan.androidstudiolite.feature.onboarding.howitworks.HowItWorksRoute +import com.ahmadkharfan.androidstudiolite.feature.onboarding.permissions.PermissionsRoute +import com.ahmadkharfan.androidstudiolite.feature.onboarding.welcome.WelcomeRoute + +/** + * Registers onboarding's destinations. Leaving the flow is a caller decision, so [onFinished] is + * supplied by the host rather than this feature knowing what comes next. + */ +fun NavGraphBuilder.onboardingGraph( + navigateTo: (String) -> Unit, + onFinished: () -> Unit, +) { + composable(OnboardingRoutes.WELCOME) { + WelcomeRoute(onGetStarted = { navigateTo(OnboardingRoutes.HOW_IT_WORKS) }) + } + composable(OnboardingRoutes.HOW_IT_WORKS) { + HowItWorksRoute(onContinue = { navigateTo(OnboardingRoutes.PERMISSIONS) }) + } + composable(OnboardingRoutes.PERMISSIONS) { + PermissionsRoute(onContinue = { navigateTo(OnboardingRoutes.COMPLETE) }) + } + composable(OnboardingRoutes.COMPLETE) { + CompleteRoute(onOpenHub = onFinished) + } +} diff --git a/feature/onboarding/src/main/java/com/ahmadkharfan/androidstudiolite/feature/onboarding/navigation/OnboardingRoutes.kt b/feature/onboarding/src/main/java/com/ahmadkharfan/androidstudiolite/feature/onboarding/navigation/OnboardingRoutes.kt new file mode 100644 index 00000000..69bfc6b3 --- /dev/null +++ b/feature/onboarding/src/main/java/com/ahmadkharfan/androidstudiolite/feature/onboarding/navigation/OnboardingRoutes.kt @@ -0,0 +1,9 @@ +package com.ahmadkharfan.androidstudiolite.feature.onboarding.navigation + +/** Routes owned by the onboarding feature. */ +object OnboardingRoutes { + const val WELCOME = "onboarding/welcome" + const val HOW_IT_WORKS = "onboarding/how-it-works" + const val PERMISSIONS = "onboarding/permissions" + const val COMPLETE = "onboarding/complete" +} diff --git a/feature/projects/build.gradle.kts b/feature/projects/build.gradle.kts index 1cbc7059..53634c1d 100644 --- a/feature/projects/build.gradle.kts +++ b/feature/projects/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) + implementation(libs.androidx.navigation.compose) testImplementation(projects.data.templates) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/feature/projects/src/main/java/com/ahmadkharfan/androidstudiolite/feature/projects/navigation/ProjectsNavigation.kt b/feature/projects/src/main/java/com/ahmadkharfan/androidstudiolite/feature/projects/navigation/ProjectsNavigation.kt new file mode 100644 index 00000000..f53cfc00 --- /dev/null +++ b/feature/projects/src/main/java/com/ahmadkharfan/androidstudiolite/feature/projects/navigation/ProjectsNavigation.kt @@ -0,0 +1,64 @@ +package com.ahmadkharfan.androidstudiolite.feature.projects.navigation + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.ahmadkharfan.androidstudiolite.feature.createproject.CreateProjectRoute +import com.ahmadkharfan.androidstudiolite.feature.folderpicker.FolderPickerRoute +import com.ahmadkharfan.androidstudiolite.feature.hub.HubRoute + +/** + * @param onOpenProject leaving for the editor is the host's decision; this feature does not know + * the editor's route. + */ +fun NavGraphBuilder.projectsGraph( + navigateTo: (String) -> Unit, + onOpenProject: (String) -> Unit, + onOpenPreferences: () -> Unit, + onCreated: (String) -> Unit, + popBackTo: (String) -> Unit, + popBack: () -> Unit, + setPreviousResult: (String, String) -> Unit, +) { + composable(ProjectsRoutes.HUB) { backStackEntry -> + val pickedFolder by backStackEntry.savedStateHandle + .getStateFlow(ProjectsRoutes.PICKED_FOLDER_RESULT, null) + .collectAsState() + HubRoute( + onOpenProject = onOpenProject, + onCreateProject = { navigateTo(ProjectsRoutes.CREATE_PROJECT) }, + onOpenPreferences = onOpenPreferences, + onBrowseFolder = { navigateTo(ProjectsRoutes.FOLDER_PICKER) }, + pickedFolder = pickedFolder, + onPickedFolderConsumed = { + backStackEntry.savedStateHandle[ProjectsRoutes.PICKED_FOLDER_RESULT] = null + }, + ) + } + + composable(ProjectsRoutes.CREATE_PROJECT) { backStackEntry -> + val pickedFolder by backStackEntry.savedStateHandle + .getStateFlow(ProjectsRoutes.PICKED_FOLDER_RESULT, null) + .collectAsState() + CreateProjectRoute( + onBack = { popBackTo(ProjectsRoutes.CREATE_PROJECT) }, + onCreated = onCreated, + onBrowseLocation = { navigateTo(ProjectsRoutes.FOLDER_PICKER) }, + pickedFolder = pickedFolder, + onPickedFolderConsumed = { + backStackEntry.savedStateHandle[ProjectsRoutes.PICKED_FOLDER_RESULT] = null + }, + ) + } + + composable(ProjectsRoutes.FOLDER_PICKER) { + FolderPickerRoute( + onCancel = popBack, + onFolderSelected = { path -> + setPreviousResult(ProjectsRoutes.PICKED_FOLDER_RESULT, path) + popBack() + }, + ) + } +} diff --git a/feature/projects/src/main/java/com/ahmadkharfan/androidstudiolite/feature/projects/navigation/ProjectsRoutes.kt b/feature/projects/src/main/java/com/ahmadkharfan/androidstudiolite/feature/projects/navigation/ProjectsRoutes.kt new file mode 100644 index 00000000..9923d1b2 --- /dev/null +++ b/feature/projects/src/main/java/com/ahmadkharfan/androidstudiolite/feature/projects/navigation/ProjectsRoutes.kt @@ -0,0 +1,14 @@ +package com.ahmadkharfan.androidstudiolite.feature.projects.navigation + +/** Routes owned by the projects feature. */ +object ProjectsRoutes { + const val HUB = "hub" + const val CREATE_PROJECT = "createProject" + const val FOLDER_PICKER = "folderPicker" + + /** + * Saved-state key for handing a browsed folder back to whichever screen requested it. Both the + * producer and the consumers live in this feature, so the protocol stays here. + */ + const val PICKED_FOLDER_RESULT = "picked_folder" +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 288cbec2..91b4e0c4 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -18,4 +18,5 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) + implementation(libs.androidx.navigation.compose) } diff --git a/feature/settings/src/main/java/com/ahmadkharfan/androidstudiolite/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/java/com/ahmadkharfan/androidstudiolite/feature/settings/navigation/SettingsNavigation.kt new file mode 100644 index 00000000..d4fc5f7d --- /dev/null +++ b/feature/settings/src/main/java/com/ahmadkharfan/androidstudiolite/feature/settings/navigation/SettingsNavigation.kt @@ -0,0 +1,34 @@ +package com.ahmadkharfan.androidstudiolite.feature.settings.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.ahmadkharfan.androidstudiolite.feature.settings.about.AboutRoute +import com.ahmadkharfan.androidstudiolite.feature.settings.aiagent.AiAgentSettingsRoute +import com.ahmadkharfan.androidstudiolite.feature.settings.buildrun.BuildRunSettingsRoute +import com.ahmadkharfan.androidstudiolite.feature.settings.editor.EditorSettingsRoute +import com.ahmadkharfan.androidstudiolite.feature.settings.general.GeneralRoute +import com.ahmadkharfan.androidstudiolite.feature.settings.gitauth.GitAuthSettingsRoute +import com.ahmadkharfan.androidstudiolite.feature.settings.root.SettingsRootRoute + +fun NavGraphBuilder.settingsGraph( + navigateTo: (String) -> Unit, + onBack: () -> Unit, +) { + composable(SettingsRoutes.ROOT) { + SettingsRootRoute( + onBack = onBack, + onOpenGeneral = { navigateTo(SettingsRoutes.GENERAL) }, + onOpenEditor = { navigateTo(SettingsRoutes.EDITOR) }, + onOpenAiAgent = { navigateTo(SettingsRoutes.AI_AGENT) }, + onOpenBuildRun = { navigateTo(SettingsRoutes.BUILD_RUN) }, + onOpenGitAuth = { navigateTo(SettingsRoutes.GIT_AUTH) }, + onOpenAbout = { navigateTo(SettingsRoutes.ABOUT) }, + ) + } + composable(SettingsRoutes.GENERAL) { GeneralRoute(onBack = onBack) } + composable(SettingsRoutes.EDITOR) { EditorSettingsRoute(onBack = onBack) } + composable(SettingsRoutes.AI_AGENT) { AiAgentSettingsRoute(onBack = onBack) } + composable(SettingsRoutes.BUILD_RUN) { BuildRunSettingsRoute(onBack = onBack) } + composable(SettingsRoutes.GIT_AUTH) { GitAuthSettingsRoute(onBack = onBack) } + composable(SettingsRoutes.ABOUT) { AboutRoute(onBack = onBack) } +} diff --git a/feature/settings/src/main/java/com/ahmadkharfan/androidstudiolite/feature/settings/navigation/SettingsRoutes.kt b/feature/settings/src/main/java/com/ahmadkharfan/androidstudiolite/feature/settings/navigation/SettingsRoutes.kt new file mode 100644 index 00000000..0f33d168 --- /dev/null +++ b/feature/settings/src/main/java/com/ahmadkharfan/androidstudiolite/feature/settings/navigation/SettingsRoutes.kt @@ -0,0 +1,12 @@ +package com.ahmadkharfan.androidstudiolite.feature.settings.navigation + +/** Routes owned by the settings feature. */ +object SettingsRoutes { + const val ROOT = "settings/root" + const val GENERAL = "settings/general" + const val EDITOR = "settings/editor" + const val AI_AGENT = "settings/aiAgent" + const val BUILD_RUN = "settings/buildRun" + const val GIT_AUTH = "settings/gitAuth" + const val ABOUT = "settings/about" +} diff --git a/feature/terminal/build.gradle.kts b/feature/terminal/build.gradle.kts index bb8f3f73..13fb27fc 100644 --- a/feature/terminal/build.gradle.kts +++ b/feature/terminal/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) + implementation(libs.androidx.navigation.compose) implementation(libs.koin.android) implementation(libs.koin.androidx.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/feature/terminal/src/main/java/com/ahmadkharfan/androidstudiolite/feature/terminal/navigation/TerminalNavigation.kt b/feature/terminal/src/main/java/com/ahmadkharfan/androidstudiolite/feature/terminal/navigation/TerminalNavigation.kt new file mode 100644 index 00000000..817d2f60 --- /dev/null +++ b/feature/terminal/src/main/java/com/ahmadkharfan/androidstudiolite/feature/terminal/navigation/TerminalNavigation.kt @@ -0,0 +1,9 @@ +package com.ahmadkharfan.androidstudiolite.feature.terminal.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.ahmadkharfan.androidstudiolite.feature.terminal.TerminalRoute + +fun NavGraphBuilder.terminalGraph(onBack: () -> Unit) { + composable(TerminalRoutes.TERMINAL) { TerminalRoute(onBack = onBack) } +} diff --git a/feature/terminal/src/main/java/com/ahmadkharfan/androidstudiolite/feature/terminal/navigation/TerminalRoutes.kt b/feature/terminal/src/main/java/com/ahmadkharfan/androidstudiolite/feature/terminal/navigation/TerminalRoutes.kt new file mode 100644 index 00000000..afc9f9a9 --- /dev/null +++ b/feature/terminal/src/main/java/com/ahmadkharfan/androidstudiolite/feature/terminal/navigation/TerminalRoutes.kt @@ -0,0 +1,6 @@ +package com.ahmadkharfan.androidstudiolite.feature.terminal.navigation + +/** Routes owned by the terminal feature. */ +object TerminalRoutes { + const val TERMINAL = "terminal" +}