Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String?>("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<String?>("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(
Expand Down Expand Up @@ -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)) },
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)}"
}
Original file line number Diff line number Diff line change
@@ -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",
)
}
}
Loading
Loading