diff --git a/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt b/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt index bb03529..0b2fb23 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt @@ -5,6 +5,7 @@ import android.content.Context import io.github.oustery.opencodephone.core.linuxenv.BootstrapManager import io.github.oustery.opencodephone.core.linuxenv.EnvLayout import io.github.oustery.opencodephone.core.linuxenv.EnvironmentRuntime +import io.github.oustery.opencodephone.core.linuxenv.SslTrust import io.github.oustery.opencodephone.core.model.AgentActivity import io.github.oustery.opencodephone.core.opencode.ConnectionConfig import io.github.oustery.opencodephone.core.opencode.OpenCodeClient @@ -44,9 +45,16 @@ class AppGraph(context: Application) { val layout: EnvLayout = EnvLayout(context) - val runtime: EnvironmentRuntime = EnvironmentRuntime(layout) + val runtime: EnvironmentRuntime = EnvironmentRuntime( + layout, + extraEnv = { SettingsStore.parseEnvExtras(settings.envExtrasRaw()) }, + ) - val bootstrap: BootstrapManager = BootstrapManager(layout, runtime, appScope, settings.opencodeVersionPin()) + val bootstrap: BootstrapManager = BootstrapManager( + layout, runtime, appScope, + settings.opencodeVersionPin(), + mirror = { settings.downloadMirror() }, + ) private val _serverConfiguration = MutableStateFlow(settings.effectiveServerConfig()) val serverConfiguration: StateFlow = _serverConfiguration @@ -101,6 +109,14 @@ class AppGraph(context: Application) { } init { + appScope.launch(Dispatchers.IO) { + // Environments installed before the CA-bundle repair never got a + // trust store, so git/curl HTTPS fails with "Problem with the SSL + // CA cert". Rebuild it once per launch — this also covers git run + // by the agent inside the environment (Files' own operations + // re-check before every network call). + if (layout.isInstalled()) SslTrust.ensure(layout.rootfsDir) + } appScope.launch { observeServerLifecycle( enabled = localServerEnabled, diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt index 443f7db..9428f30 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt @@ -377,6 +377,21 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { ) } private suspend fun migrate(repo: File) = GitAuth.migrateRemotes { exec(repo, it) } + + /** Remote-operation options (SSL verification, proxy) from Settings → Git. */ + private fun gitNet(): GitAuth.NetOptions { + val cfg = graph.settings.gitConfig() + return GitAuth.NetOptions(sslVerify = cfg.sslVerify, proxy = cfg.proxy) + } + + /** + * Idempotent CA-bundle repair before any network git operation (see + * [SslTrust]): covers environments installed before the repair existed. + * App-side file check, near-zero cost when the bundle already exists. + */ + private suspend fun ensureSslTrust() { + withContext(Dispatchers.IO) { SslTrust.ensure(graph.layout.rootfsDir) } + } private suspend fun refreshGit() { val repo = project ?: return // -z keeps paths unquoted and NUL-separated, so spaces and exotic @@ -413,7 +428,15 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { } val target = files.checked(File(workspace, name)) check(!target.exists()) { graph.appContext.getString(R.string.files_clone_exists) } - val result = exec(workspace, GitAuth.command("clone", "--", clean, name), GitAuth.environment(clean, graph.settings.gitConfig().githubToken)) + ensureSslTrust() + // Optional shallow clone (Settings → Git): one commit deep — + // much less traffic and disk on mobile networks. + val depth = if (graph.settings.gitConfig().shallowClone) listOf("--depth", "1") else emptyList() + val result = exec( + workspace, + GitAuth.command(gitNet(), *(listOf("clone") + depth + listOf("--", clean, name)).toTypedArray()), + GitAuth.environment(clean, graph.settings.gitConfig().githubToken), + ) if (result.exitCode != 0) throw gitFailure(R.string.git_action_clone, result) project = target publishProject(target) @@ -503,10 +526,11 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { task(graph.appContext.getString(R.string.files_busy_pushing)) { gitLock.withLock { val repo = checkNotNull(project) { graph.appContext.getString(R.string.files_no_repo) } migrate(repo) + ensureSslTrust() val remote = exec(repo, listOf("git", "remote", "get-url", "--push", "origin")) check(remote.exitCode == 0) { graph.appContext.getString(R.string.git_no_push_remote) } val clean = GitAuth.cleanUrl(remote.output.trim()) - val result = exec(repo, GitAuth.command("push", clean, "HEAD"), GitAuth.environment(clean, graph.settings.gitConfig().githubToken)) + val result = exec(repo, GitAuth.command(gitNet(), "push", clean, "HEAD"), GitAuth.environment(clean, graph.settings.gitConfig().githubToken)) if (result.exitCode != 0) throw gitFailure(R.string.git_action_push, result) _state.update { it.copy( @@ -532,10 +556,11 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { require(branch.isNotEmpty() && branch != "HEAD") { graph.appContext.getString(R.string.git_no_branch) } val remote = exec(repo, listOf("git", "remote", "get-url", "origin")) check(remote.exitCode == 0) { graph.appContext.getString(R.string.git_no_remote) } + ensureSslTrust() val clean = GitAuth.cleanUrl(remote.output.trim()) val result = exec( repo, - GitAuth.command("pull", "--ff-only", clean, branch), + GitAuth.command(gitNet(), "pull", "--ff-only", clean, branch), GitAuth.environment(clean, graph.settings.gitConfig().githubToken), ) if (result.exitCode != 0) throw gitFailure(R.string.git_action_pull, result) diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt index 1a9dbfb..ad8ab69 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt @@ -97,6 +97,9 @@ private val KNOWN_PROVIDERS = listOf( "deepseek", "mistral", "xai", "azure", "amazon-bedrock", "github-copilot", ) +/** Mirror prefix for GitHub release downloads (Settings → Environment). */ +private const val MIRROR_GHPROXY_NET = "https://ghproxy.net" + /** * Groups of the redesigned settings screen (Android-Settings pattern): a * short root list of [OcGroupCard]s, each opening one focused sub-page. @@ -711,6 +714,63 @@ private fun GitPage(vm: SettingsViewModel, state: SettingsUiState, onBack: () -> ) } } + + // ---- Network & cloning -------------------------------------- + OcSectionHeader(stringResource(R.string.settings_git_network_header)) + Card(shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OcToggleableRow( + checked = state.git.shallowClone, + onCheckedChange = vm::setGitShallow, + role = Role.Switch, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_git_shallow), style = MaterialTheme.typography.bodyLarge) + Text( + stringResource(R.string.settings_git_shallow_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = state.git.shallowClone, onCheckedChange = null) + } + OcToggleableRow( + checked = state.git.sslVerify, + onCheckedChange = vm::setGitSslVerify, + role = Role.Switch, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_git_ssl), style = MaterialTheme.typography.bodyLarge) + Text( + stringResource( + if (state.git.sslVerify) R.string.settings_git_ssl_hint + else R.string.settings_git_ssl_warning, + ), + style = MaterialTheme.typography.bodySmall, + color = if (state.git.sslVerify) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error, + ) + } + Switch(checked = state.git.sslVerify, onCheckedChange = null) + } + OutlinedTextField( + value = state.git.proxy, + onValueChange = { vm.setGitProxy(it) }, + label = { Text(stringResource(R.string.settings_git_proxy)) }, + placeholder = { Text(stringResource(R.string.settings_git_proxy_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + ) + Text( + stringResource(R.string.settings_git_proxy_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Spacer(Modifier.height(16.dp)) } } @@ -744,6 +804,21 @@ private fun EnvironmentPage(vm: SettingsViewModel, state: SettingsUiState, onBac label = stringResource(R.string.settings_env_workspace), value = vm.workspacePath(), ) + state.envInfo?.let { info -> + OcInfoRow( + label = stringResource(R.string.settings_env_cli_version), + value = info.cliVersion ?: stringResource(R.string.settings_env_cli_missing), + ) + OcInfoRow( + label = stringResource(R.string.settings_env_debian), + value = "${info.debianSuite} · ${stringResource(R.string.settings_env_size, info.sizeMb)}", + ) + } + } + } + + Card(shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedTextField( value = state.opencodePin, onValueChange = { vm.setOpencodePin(it) }, @@ -757,6 +832,60 @@ private fun EnvironmentPage(vm: SettingsViewModel, state: SettingsUiState, onBac style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + // Download mirror for GitHub release assets: a transport + // fix for networks where github.com stalls. Integrity is + // unaffected — every archive stays digest-pinned. + Text( + stringResource(R.string.settings_env_mirror), + style = MaterialTheme.typography.bodyLarge, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.downloadMirror.isBlank(), + onClick = { vm.setDownloadMirror("") }, + label = { Text(stringResource(R.string.settings_env_mirror_direct)) }, + ) + FilterChip( + selected = state.downloadMirror == MIRROR_GHPROXY_NET, + onClick = { vm.setDownloadMirror(MIRROR_GHPROXY_NET) }, + label = { Text(stringResource(R.string.settings_env_mirror_ghproxy)) }, + ) + } + Text( + stringResource(R.string.settings_env_mirror_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Extra environment variables applied to every guest + // process. Raw text is kept verbatim; invalid lines are + // skipped by the parser and counted below. + OutlinedTextField( + value = state.envExtras, + onValueChange = { vm.setEnvExtras(it) }, + label = { Text(stringResource(R.string.settings_env_extras)) }, + placeholder = { Text(stringResource(R.string.settings_env_extras_placeholder)) }, + minLines = 2, + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + ) + Text( + if (state.invalidEnvExtras > 0) { + stringResource(R.string.settings_env_extras_invalid, state.invalidEnvExtras) + } else { + stringResource(R.string.settings_env_extras_hint) + }, + style = MaterialTheme.typography.bodySmall, + color = if (state.invalidEnvExtras > 0) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Card(shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedButton(onClick = { vm.showReinstallConfirm(true) }) { Text(stringResource(R.string.settings_env_reinstall)) } @@ -875,15 +1004,22 @@ private fun AppPage( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { exportLauncher.launch("opencodephone-settings.json") }) { - Text(stringResource(R.string.settings_backup_export)) - } - OutlinedButton(onClick = { + // Full-width stacked actions: two long labels share one + // row badly on phone widths (the text wraps mid-word and + // the pair reads as one merged blob). + Button( + onClick = { exportLauncher.launch("opencodephone-settings.json") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_backup_export)) + } + OutlinedButton( + onClick = { importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream")) - }) { - Text(stringResource(R.string.settings_backup_import)) - } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_backup_import)) } state.dataMessage?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt index a05601f..83b86a5 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import io.github.oustery.opencodephone.AppGraph import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.core.linuxenv.ServerState import io.github.oustery.opencodephone.core.model.BootstrapState +import io.github.oustery.opencodephone.core.model.Pins import io.github.oustery.opencodephone.core.opencode.ConsolePollResult import io.github.oustery.opencodephone.core.opencode.OpenCodeClient import io.github.oustery.opencodephone.core.opencode.OpenCodeConsoleClient @@ -26,6 +27,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +/** Read-only facts about the installed environment (Settings → Environment). */ +data class EnvInfo( + val cliVersion: String?, + val debianSuite: String, + val sizeMb: Long, +) + data class SettingsUiState( val server: ServerConfig = ServerConfig(), val providers: List = emptyList(), @@ -37,6 +45,10 @@ data class SettingsUiState( val connectionTesting: Boolean = false, val serverDirty: Boolean = false, val bootstrap: BootstrapState = BootstrapState(), + val envInfo: EnvInfo? = null, + val downloadMirror: String = "", + val envExtras: String = "", + val invalidEnvExtras: Int = 0, val serverStateLabel: String = "", val serverState: ServerState = ServerState.Stopped, val serverError: String? = null, @@ -130,6 +142,7 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { fun restartServer() { if (!_state.value.serverDirty) graph.restartLocalServer() } fun reload() { + val extrasRaw = settings.envExtrasRaw() _state.update { it.copy( serverDirty = false, @@ -141,8 +154,35 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { git = settings.gitConfig(), opencodePin = settings.opencodeVersionPin(), account = settings.opencodeAccount(), + downloadMirror = settings.downloadMirror(), + envExtras = extrasRaw, + invalidEnvExtras = SettingsStore.invalidEnvExtrasCount(extrasRaw), ) } + refreshEnvInfo() + } + + /** Installed-environment facts (version, suite, disk) computed off the main thread. */ + private fun refreshEnvInfo() { + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val info = if (!graph.layout.isInstalled()) { + null + } else { + val stamp = runCatching { graph.layout.installedStamp.readText() }.getOrDefault("") + val parts = stamp.split(';').mapNotNull { + val idx = it.indexOf('=') + if (idx <= 0) null else it.substring(0, idx) to it.substring(idx + 1) + }.toMap() + EnvInfo( + cliVersion = parts["opencode"]?.ifBlank { null }, + debianSuite = parts["debian"]?.ifBlank { null } ?: Pins.DEBIAN_SUITE, + sizeMb = graph.layout.baseDir.walkBottomUp() + .filter { it.isFile } + .sumOf { it.length() } / (1024 * 1024), + ) + } + _state.update { it.copy(envInfo = info) } + } } // ------------------------------------------------------------- Server @@ -532,6 +572,12 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { fun setGitToken(value: String) = persistGit { it.copy(githubToken = value.trim()) } + fun setGitShallow(value: Boolean) = persistGit { it.copy(shallowClone = value) } + + fun setGitSslVerify(value: Boolean) = persistGit { it.copy(sslVerify = value) } + + fun setGitProxy(value: String) = persistGit { it.copy(proxy = value.trim()) } + private fun persistGit(transform: (GitConfig) -> GitConfig) { val updated = transform(_state.value.git) settings.setGitConfig(updated) @@ -546,6 +592,20 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { _state.update { it.copy(opencodePin = value.trim()) } } + // -------------------------------------------------------- Environment + + fun setDownloadMirror(url: String) { + settings.setDownloadMirror(url) + _state.update { it.copy(downloadMirror = url) } + } + + fun setEnvExtras(value: String) { + settings.setEnvExtrasRaw(value) + _state.update { + it.copy(envExtras = value, invalidEnvExtras = SettingsStore.invalidEnvExtrasCount(value)) + } + } + // -------------------------------------------------------- Appearance /** Persisted through the graph so the live theme flow and the store stay in sync (E-016). */ diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6c9dba1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..1473ec7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 33a083b..30347dd 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,15 +1,11 @@ - + - - - - - + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index d486c6f..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index b3e26b4..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,5 @@ - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index b3e26b4..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,5 +2,5 @@ - + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7ef3e20..b7cba57 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -196,6 +196,15 @@ Email Git Токен GitHub (PAT) Токен используется для клонирования и push в приватные GitHub-репозитории. + Сеть и клонирование + Неглубокое клонирование + Клонируется только последний коммит (--depth 1): заметно быстрее и экономит место. Pull и push продолжают работать. + Проверять SSL-сертификаты + HTTPS-соединения с git-серверами проверяются по набору корневых сертификатов окружения. + Проверка выключена: соединения не проверяются. Используйте только с самоподписанными серверами или доверенным прокси. + HTTP-прокси для git + хост:порт + Применяется к clone, pull и push. Пусто — прямое соединение. Окружение Linux Установка Рабочая папка @@ -203,6 +212,18 @@ Версия будет установлена при следующем сбросе окружения. Изменения применяются после переустановки. Переустановить окружение Это удалит окружение Debian (файлы рабочей папки сохранятся) и повторит полную установку. Продолжить? + OpenCode CLI + не обнаружена + Debian + %1$d МБ + Зеркало загрузок + GitHub напрямую + через ghproxy.net + Загрузки релизов GitHub (архивы инструментов) идут через зеркало, когда github.com медленный или недоступен. Целостность не меняется: каждая загрузка проверяется контрольной суммой. + Переменные окружения + КЛЮЧ=значение + По одной переменной на строку; применяются ко всем процессам окружения (терминал, git, сервер). В резервные копии не выгружаются. + Пропущено некорректных строк: %1$d (ожидается КЛЮЧ=значение с допустимым именем). Оформление Динамические цвета Использовать цвета системных обоев на Android 12+. Когда выключено, применяется фирменная бирюзовая палитра. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2da5061..9baef25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -229,6 +229,15 @@ Git email GitHub token (PAT) The token is used for cloning and pushing private GitHub repositories. + Network & cloning + Shallow clone + Clone only the latest commit (--depth 1): much faster and saves disk space. Pull and push keep working. + Verify SSL certificates + HTTPS connections to git servers are verified against the environment CA bundle. + Verification is off: connections are not checked. Use only with self-signed servers or a trusted proxy. + HTTP proxy for git + host:port + Applies to clone, pull and push. Leave empty for a direct connection. Linux environment Bootstrap Workspace @@ -236,6 +245,18 @@ Version installed at the next environment reset. Changes apply after reinstall. Reinstall environment This deletes the Debian environment (workspace files are kept) and repeats the full setup. Continue? + OpenCode CLI + not detected + Debian + %1$d MB + Download mirror + GitHub direct + via ghproxy.net + Routes GitHub release downloads (the toolset archives) through the mirror when github.com is slow or unreachable. Integrity is unaffected: every archive stays checksum-verified. + Environment variables + KEY=value + One variable per line, applied to every process inside the environment (terminal, git, server). Never exported in backups. + %1$d invalid lines skipped — expected KEY=value with a valid name. Appearance Dynamic color Use wallpaper-based colors on Android 12+. When off, the teal brand palette is applied. diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt index 4a3aaca..d422eae 100644 --- a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt @@ -43,6 +43,8 @@ class BootstrapManager( private val runtime: EnvironmentRuntime, private val scope: CoroutineScope, private var opencodeVersion: String = Pins.OPENCODE_VERSION, + /** HTTPS mirror prefix for GitHub downloads, read at run time from settings. */ + private val mirror: () -> String = { "" }, ) { private val registry = DockerRegistryClient(DockerRegistryClient.longTimeoutClient()) @@ -222,7 +224,7 @@ class BootstrapManager( "Installing tools (git, curl, ripgrep)", message = "Downloading the toolset package", ) - toolset.install(layout, abi.debianArch) { read, total -> + toolset.install(layout, abi.debianArch, mirror = mirror()) { read, total -> val fraction = if (total > 0) read.toFloat() / total else null setState( BootstrapPhase.INSTALLING_TOOLS, @@ -233,6 +235,14 @@ class BootstrapManager( } log("toolset ${abi.debianArch} installed") + // CA trust store: the toolset carries the certificate files but + // not the combined bundle (its post-install script never runs). + // Without it git/curl fail with "Problem with the SSL CA cert". + val caCount = checkNotNull(SslTrust.rebuild(layout.rootfsDir)) { + "Toolset ${abi.debianArch} carries no CA certificates" + } + log("CA bundle rebuilt: $caCount certificates -> ${SslTrust.BUNDLE_PATH}") + // Verify the tools actually run (short, low-CPU command). val check = runtime.exec( listOf("bash", "-lc", "git --version && curl --version | head -1"), diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/EnvironmentRuntime.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/EnvironmentRuntime.kt index c1a5634..ccd1235 100644 --- a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/EnvironmentRuntime.kt +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/EnvironmentRuntime.kt @@ -24,6 +24,8 @@ sealed interface ServerState { */ class EnvironmentRuntime( private val layout: EnvLayout, + /** User-defined variables (Settings → Environment) merged into every guest process. */ + private val extraEnv: () -> Map = { emptyMap() }, ) { private val interactiveGuard = Any() @@ -98,6 +100,10 @@ class EnvironmentRuntime( val pb = ProcessBuilder(argv) val environ = pb.environment() environ.putAll(PRootCommand.baseEnvironment(layout.tmpDir)) + // CA trust store for git/curl (see SslTrust), then user-defined + // variables, then the per-call env last so callers stay authoritative. + environ.putAll(SslTrust.guestBundleEnv) + environ.putAll(extraEnv()) environ.putAll(env) if (mergeOutput) pb.redirectErrorStream(true) return pb diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/GitAuth.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/GitAuth.kt index 71b9f4e..84ea898 100644 --- a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/GitAuth.kt +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/GitAuth.kt @@ -6,6 +6,17 @@ import kotlinx.coroutines.withContext /** Credentials live only in the child environment, never URL, argv, config or helper files. */ object GitAuth { + + /** + * Per-operation network options configured in Settings → Git. Local + * operations (status, commit, config) use the defaults; remote ones + * (clone, pull, push) receive the user's choices. + */ + data class NetOptions( + val sslVerify: Boolean = true, + val proxy: String = "", + ) + fun cleanUrl(value: String): String { val uri = URI(value.trim()) require(uri.scheme == "https" && uri.host != null && uri.rawQuery == null && uri.rawFragment == null) { @@ -14,10 +25,21 @@ object GitAuth { return URI("https", null, uri.host, uri.port, uri.path, null, null).toASCIIString() } - fun command(vararg args: String): List = listOf( - "git", "-c", "credential.helper=", "-c", "credential.helper=$helper", - "-c", "http.sslVerify=true", "-c", "http.followRedirects=false", "-c", "core.hooksPath=/dev/null", - ) + args + /** Local (non-network) operations: default options, SSL verification on. */ + fun command(vararg args: String): List = command(NetOptions(), *args) + + fun command(options: NetOptions, vararg args: String): List = buildList { + add("git") + add("-c"); add("credential.helper=") + add("-c"); add("credential.helper=$helper") + add("-c"); add("http.sslVerify=${options.sslVerify}") + add("-c"); add("http.followRedirects=false") + add("-c"); add("core.hooksPath=/dev/null") + if (options.proxy.isNotBlank()) { + add("-c"); add("http.proxy=${options.proxy.trim()}") + } + addAll(args) + } fun environment(url: String, token: String): Map { val uri = URI(cleanUrl(url)) diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/SslTrust.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/SslTrust.kt new file mode 100644 index 0000000..bdc4e53 --- /dev/null +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/SslTrust.kt @@ -0,0 +1,76 @@ +package io.github.oustery.opencodephone.core.linuxenv + +import java.io.File + +/** + * Rebuilds the guest CA trust store. + * + * The toolset ships the `ca-certificates` package's certificate files + * (`usr/share/ca-certificates/**/*.crt`), but the combined bundle + * `/etc/ssl/certs/ca-certificates.crt` is generated by the package's + * post-install script (`update-ca-certificates`) — and bootstrap extracts + * packages app-side without running guest scripts (ADR-008). Git and curl + * then find no CA store and every HTTPS operation fails with + * `Problem with the SSL CA cert (path? access rights?)`. + * + * The bundle is a plain concatenation of the packaged PEM certificates, so + * the app can rebuild it itself — a pure file write, no guest process. + */ +object SslTrust { + + /** Path inside the rootfs where Debian tooling looks for the CA bundle. */ + const val BUNDLE_PATH: String = "/etc/ssl/certs/ca-certificates.crt" + + /** + * Belt and suspenders for every guest process: point git/libcurl and + * OpenSSL clients at the bundle explicitly, even when a build compiled a + * different default path. Written into the environment alongside the + * bundle itself. + */ + val guestBundleEnv: Map = mapOf( + "GIT_SSL_CAINFO" to BUNDLE_PATH, + "SSL_CERT_FILE" to BUNDLE_PATH, + ) + + fun bundleFile(rootfsDir: File): File = File(rootfsDir, BUNDLE_PATH.removePrefix("/")) + + /** + * Concatenate every packaged certificate into the bundle. Matches + * `update-ca-certificates` sources: the package store + * (`usr/share/ca-certificates`) plus the administrator store + * (`usr/local/share/ca-certificates`). Returns the number of + * certificates written, or null when the rootfs carries none at all + * (a toolset without `ca-certificates`). + */ + fun rebuild(rootfsDir: File): Int? { + val sources = listOf("usr/share/ca-certificates", "usr/local/share/ca-certificates") + .flatMap { dir -> File(rootfsDir, dir).takeIf { it.isDirectory }?.walkTopDown().orEmpty().toList() } + .filter { it.isFile && it.extension == "crt" } + .sortedBy { it.absolutePath } + if (sources.isEmpty()) return null + val target = bundleFile(rootfsDir) + target.parentFile.mkdirs() + val tmp = File(target.parentFile, target.name + ".tmp") + var written = 0 + tmp.outputStream().buffered().use { out -> + for (cert in sources) { + val pem = cert.readText().trim() + if (!pem.contains("BEGIN CERTIFICATE")) continue + out.write((pem + "\n").toByteArray(Charsets.UTF_8)) + written++ + } + } + check(tmp.renameTo(target)) { "Cannot write CA bundle ${target.absolutePath}" } + return written + } + + /** + * Cheap idempotent repair used at app start and before network git + * operations: rewrites the bundle only when it is missing or empty. + */ + fun ensure(rootfsDir: File): Boolean { + val bundle = bundleFile(rootfsDir) + if (bundle.isFile && bundle.length() > 0) return true + return rebuild(rootfsDir) != null + } +} diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstaller.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstaller.kt index 94651f1..39924b5 100644 --- a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstaller.kt +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstaller.kt @@ -24,6 +24,19 @@ import java.security.MessageDigest */ class ToolsetInstaller(private val callFactory: Call.Factory) { + companion object { + /** + * Route a download through an optional HTTPS mirror prefix + * (Settings → Environment). Only github.com release assets are + * mirrored; anything else (npm, docker registry) keeps its URL. + * Integrity never depends on the mirror: archives stay + * digest-pinned and checksum-verified. + */ + fun applyMirror(url: String, mirror: String): String = + if (mirror.isBlank() || !url.startsWith("https://github.com/")) url + else mirror.trim().trimEnd('/') + "/" + url + } + /** * Download the toolset for [arch] into [layout.downloadDir], verify its * sha256 against [Pins.TOOLSET_SHA256] and extract it into the rootfs. @@ -31,11 +44,12 @@ class ToolsetInstaller(private val callFactory: Call.Factory) { suspend fun install( layout: EnvLayout, arch: String, + mirror: String = "", onProgress: (read: Long, total: Long) -> Unit, ) = withContext(Dispatchers.IO) { val digest = Pins.TOOLSET_SHA256[arch] ?: error("No toolset pinned for architecture $arch") - val url = Pins.TOOLSET_URL_PREFIX + arch + Pins.TOOLSET_URL_SUFFIX + val url = applyMirror(Pins.TOOLSET_URL_PREFIX + arch + Pins.TOOLSET_URL_SUFFIX, mirror) val archive = File(layout.downloadDir, "toolset-$arch.tar.gz") val sha = MessageDigest.getInstance("SHA-256") diff --git a/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/GitAuthTest.kt b/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/GitAuthTest.kt index 6c8b968..b48fd0e 100644 --- a/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/GitAuthTest.kt +++ b/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/GitAuthTest.kt @@ -6,6 +6,21 @@ import org.junit.Test import java.nio.file.Files class GitAuthTest { + @Test fun `network options toggle ssl verification and proxy without touching credentials`() { + val plain = GitAuth.command("clone", "--", "https://github.com/a/b") + assertTrue(plain.contains("http.sslVerify=true")) + assertFalse(plain.any { it.startsWith("http.proxy=") }) + val relaxed = GitAuth.command( + GitAuth.NetOptions(sslVerify = false, proxy = "127.0.0.1:8888"), + "clone", "--", "https://github.com/a/b", + ) + assertTrue(relaxed.contains("http.sslVerify=false")) + assertTrue(relaxed.contains("http.proxy=127.0.0.1:8888")) + // The credential-helper contract is untouched by the options: secrets + // still flow only through the environment, never argv. + assertTrue(relaxed.contains("credential.helper=")) + assertFalse(GitAuth.command(GitAuth.NetOptions(proxy = " "), "pull").any { it.startsWith("http.proxy=") }) + } @Test fun `helper releases a fake token only for HTTPS github without persisting it`() { fun fill(host: String): Pair { val builder = ProcessBuilder(GitAuth.command("credential", "fill")).redirectErrorStream(true) diff --git a/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/SslTrustTest.kt b/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/SslTrustTest.kt new file mode 100644 index 0000000..4f8237b --- /dev/null +++ b/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/SslTrustTest.kt @@ -0,0 +1,76 @@ +package io.github.oustery.opencodephone.core.linuxenv + +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class SslTrustTest { + + private fun cert(name: String, serial: String): String = """ + -----BEGIN CERTIFICATE----- + MIIB-$name-$serial + filler line for the test PEM body + -----END CERTIFICATE----- + """.trimIndent() + "\n" + + @Test fun `rebuild concatenates packaged certificates into the bundle path`() { + val rootfs = Files.createTempDirectory("rootfs").toFile() + try { + val mozilla = File(rootfs, "usr/share/ca-certificates/mozilla").apply { mkdirs() } + val local = File(rootfs, "usr/local/share/ca-certificates").apply { mkdirs() } + File(mozilla, "b.crt").writeText(cert("B", "1111")) + File(mozilla, "a.crt").writeText(cert("A", "2222")) + File(local, "custom.crt").writeText(cert("C", "3333")) + File(mozilla, "readme.txt").writeText("not a certificate") + + val count = SslTrust.rebuild(rootfs) + assertEquals(3, count) + val bundle = SslTrust.bundleFile(rootfs) + assertTrue(bundle.isFile) + val text = bundle.readText() + // Deterministic order: sorted by absolute path, so the local + // store (usr/local/share) precedes usr/share. + assertTrue(text.indexOf("C-3333") < text.indexOf("A-2222")) + assertTrue(text.indexOf("A-2222") < text.indexOf("B-1111")) + assertEquals(3, Regex("BEGIN CERTIFICATE").findAll(text).count()) + // No temp file left behind. + assertEquals(listOf("ca-certificates.crt"), bundle.parentFile.list()?.sorted()) + } finally { rootfs.deleteRecursively() } + } + + @Test fun `rebuild returns null when the rootfs carries no certificates`() { + val rootfs = Files.createTempDirectory("rootfs-empty").toFile() + try { + assertNull(SslTrust.rebuild(rootfs)) + File(rootfs, "usr/share/ca-certificates").mkdirs() + assertNull(SslTrust.rebuild(rootfs)) + } finally { rootfs.deleteRecursively() } + } + + @Test fun `ensure rewrites only when the bundle is missing or empty`() { + val rootfs = Files.createTempDirectory("rootfs-ensure").toFile() + try { + val mozilla = File(rootfs, "usr/share/ca-certificates/mozilla").apply { mkdirs() } + File(mozilla, "a.crt").writeText(cert("A", "4444")) + // Nothing yet: repair writes the bundle. + assertFalse(SslTrust.bundleFile(rootfs).exists()) + assertTrue(SslTrust.ensure(rootfs)) + val bundle = SslTrust.bundleFile(rootfs) + val stamp = bundle.lastModified() + // Present and non-empty: no rewrite (timestamp unchanged). + assertTrue(SslTrust.ensure(rootfs)) + assertEquals(stamp, bundle.lastModified()) + // Emptied bundle (failed previous repair): rewritten. + bundle.writeText("") + assertTrue(SslTrust.ensure(rootfs)) + assertTrue(bundle.length() > 0) + } finally { rootfs.deleteRecursively() } + } + + @Test fun `guest env points git and openssl clients at the bundle`() { + assertEquals(SslTrust.BUNDLE_PATH, SslTrust.guestBundleEnv["GIT_SSL_CAINFO"]) + assertEquals(SslTrust.BUNDLE_PATH, SslTrust.guestBundleEnv["SSL_CERT_FILE"]) + assertTrue(SslTrust.BUNDLE_PATH.startsWith("/etc/ssl/certs/")) + } +} diff --git a/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstallerTest.kt b/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstallerTest.kt new file mode 100644 index 0000000..4459e6f --- /dev/null +++ b/core/linuxenv/src/test/java/io/github/oustery/opencodephone/core/linuxenv/ToolsetInstallerTest.kt @@ -0,0 +1,25 @@ +package io.github.oustery.opencodephone.core.linuxenv + +import org.junit.Assert.* +import org.junit.Test + +class ToolsetInstallerTest { + @Test fun `mirror prefix routes only github release assets and keeps direct URLs intact`() { + val asset = "https://github.com/oustery/opencodephone/releases/download/toolset-v1/toolset-arm64.tar.gz" + // No mirror configured: byte-identical URL. + assertEquals(asset, ToolsetInstaller.applyMirror(asset, "")) + assertEquals(asset, ToolsetInstaller.applyMirror(asset, " ")) + // Mirror prefixes the URL; trailing slashes on the mirror are trimmed. + assertEquals( + "https://ghproxy.net/$asset", + ToolsetInstaller.applyMirror(asset, "https://ghproxy.net"), + ) + assertEquals( + "https://ghproxy.net/$asset", + ToolsetInstaller.applyMirror(asset, "https://ghproxy.net/"), + ) + // Non-github endpoints (npm, docker registry) are never mirrored. + val npm = "https://registry.npmjs.org/opencode-linux-arm64/1.19.5.tgz" + assertEquals(npm, ToolsetInstaller.applyMirror(npm, "https://ghproxy.net")) + } +} diff --git a/core/settings/src/main/java/io/github/oustery/opencodephone/core/settings/SettingsStore.kt b/core/settings/src/main/java/io/github/oustery/opencodephone/core/settings/SettingsStore.kt index 19c403e..db7cf42 100644 --- a/core/settings/src/main/java/io/github/oustery/opencodephone/core/settings/SettingsStore.kt +++ b/core/settings/src/main/java/io/github/oustery/opencodephone/core/settings/SettingsStore.kt @@ -32,6 +32,16 @@ data class GitConfig( val userName: String = "", val userEmail: String = "", val githubToken: String = "", + /** Clone with `--depth 1`: faster on mobile networks, less disk (opt-in). */ + val shallowClone: Boolean = false, + /** + * HTTPS certificate verification for remote git operations. On by + * default; turning it off is an explicit escape hatch for self-signed + * servers and proxy MITM setups, shown with a warning in the UI. + */ + val sslVerify: Boolean = true, + /** HTTP proxy for git network operations (`host:port`), empty = direct. */ + val proxy: String = "", ) data class ModelSelection( @@ -234,12 +244,18 @@ class SettingsStore(private val context: Context) { userName = prefs.getString(KEY_GIT_NAME, "") ?: "", userEmail = prefs.getString(KEY_GIT_EMAIL, "") ?: "", githubToken = prefs.getString(KEY_GIT_TOKEN, "") ?: "", + shallowClone = prefs.getBoolean(KEY_GIT_SHALLOW, false), + sslVerify = prefs.getBoolean(KEY_GIT_SSL_VERIFY, true), + proxy = prefs.getString(KEY_GIT_PROXY, "") ?: "", ) fun setGitConfig(config: GitConfig) = edit { it.putString(KEY_GIT_NAME, config.userName) it.putString(KEY_GIT_EMAIL, config.userEmail) it.putString(KEY_GIT_TOKEN, config.githubToken) + it.putBoolean(KEY_GIT_SHALLOW, config.shallowClone) + it.putBoolean(KEY_GIT_SSL_VERIFY, config.sslVerify) + it.putString(KEY_GIT_PROXY, config.proxy) } // ---------------------------------------------------------- Versions @@ -253,6 +269,31 @@ class SettingsStore(private val context: Context) { it.putString(KEY_OPENCODE_PIN, version) } + // ------------------------------------------------------- Environment + + /** + * Optional HTTPS mirror prefix for GitHub release downloads (toolset + * archives) used when github.com is slow or unreachable. Empty = direct. + * Only transport changes: every download stays digest-pinned, so a + * mirror cannot swap in different bytes. + */ + fun downloadMirror(): String = prefs.getString(KEY_DOWNLOAD_MIRROR, "") ?: "" + + fun setDownloadMirror(url: String) = edit { + it.putString(KEY_DOWNLOAD_MIRROR, url.trim()) + } + + /** + * Raw user-defined environment variables applied to every guest process + * (terminal, git, server), one `KEY=value` per line. Stored verbatim and + * never exported (the values may hold secrets). + */ + fun envExtrasRaw(): String = prefs.getString(KEY_ENV_EXTRAS, "") ?: "" + + fun setEnvExtrasRaw(value: String) = edit { + it.putString(KEY_ENV_EXTRAS, value) + } + fun hasCompletedBootstrap(): Boolean = prefs.getBoolean(KEY_BOOTSTRAP_DONE, false) fun setBootstrapCompleted() = edit { it.putBoolean(KEY_BOOTSTRAP_DONE, true) } @@ -312,7 +353,10 @@ class SettingsStore(private val context: Context) { "git", org.json.JSONObject() .put("userName", git.userName) - .put("userEmail", git.userEmail), + .put("userEmail", git.userEmail) + .put("shallowClone", git.shallowClone) + .put("sslVerify", git.sslVerify) + .put("proxy", git.proxy), ) .put( "model", @@ -322,6 +366,7 @@ class SettingsStore(private val context: Context) { ) .put("agent", agentName().orEmpty()) .put("opencodePin", opencodeVersionPin()) + .put("downloadMirror", downloadMirror()) .put("dynamicColor", dynamicColor()) .put("setupChoice", setupChoice().name) .put("selectedProject", selectedProject().orEmpty()) @@ -361,6 +406,9 @@ class SettingsStore(private val context: Context) { userName = g.optString("userName", current.userName), userEmail = g.optString("userEmail", current.userEmail), githubToken = current.githubToken, + shallowClone = g.optBoolean("shallowClone", current.shallowClone), + sslVerify = g.optBoolean("sslVerify", current.sslVerify), + proxy = g.optString("proxy", current.proxy), ), ) sections++ @@ -378,6 +426,7 @@ class SettingsStore(private val context: Context) { val pin = json.optString("opencodePin", Pins.OPENCODE_VERSION) if (pin.isNotBlank()) setOpencodeVersionPin(pin) } + if (json.has("downloadMirror")) setDownloadMirror(json.optString("downloadMirror")) if (json.has("dynamicColor")) setDynamicColor(json.optBoolean("dynamicColor", false)) if (json.has("setupChoice")) { runCatching { setSetupChoice(SetupChoice.valueOf(json.getString("setupChoice"))) } @@ -394,6 +443,32 @@ class SettingsStore(private val context: Context) { keys.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS) } + /** + * Parse user-defined environment variables (`KEY=value`, one per + * line, `#` comments). Invalid lines (no `=`, bad key shape) are + * skipped — the raw text stays untouched so the user can fix it. + */ + fun parseEnvExtras(raw: String): Map = raw.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull { line -> + val index = line.indexOf('=') + if (index <= 0) return@mapNotNull null + val key = line.substring(0, index).trim() + if (!key.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) return@mapNotNull null + key to line.substring(index + 1).trim() + } + .toMap() + + /** Number of non-comment lines that parseEnvExtras has to skip. */ + fun invalidEnvExtrasCount(raw: String): Int { + val lines = raw.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .toList() + return lines.size - parseEnvExtras(raw).size + } + private const val KEY_LOCAL_SERVER_SECRET = "local_server_secret" private const val KEY_SERVER_MODE = "server_mode" private const val KEY_SERVER_HOST = "server_host" @@ -407,7 +482,12 @@ class SettingsStore(private val context: Context) { private const val KEY_GIT_NAME = "git_user_name" private const val KEY_GIT_EMAIL = "git_user_email" private const val KEY_GIT_TOKEN = "git_github_token" + private const val KEY_GIT_SHALLOW = "git_shallow_clone" + private const val KEY_GIT_SSL_VERIFY = "git_ssl_verify" + private const val KEY_GIT_PROXY = "git_http_proxy" private const val KEY_OPENCODE_PIN = "opencode_version_pin" + private const val KEY_DOWNLOAD_MIRROR = "download_mirror" + private const val KEY_ENV_EXTRAS = "env_extras_raw" private const val KEY_BOOTSTRAP_DONE = "bootstrap_completed" private const val KEY_DYNAMIC_COLOR = "dynamic_color" private const val KEY_SETUP_CHOICE = "setup_choice" diff --git a/core/settings/src/test/java/io/github/oustery/opencodephone/core/settings/EnvExtrasParsingTest.kt b/core/settings/src/test/java/io/github/oustery/opencodephone/core/settings/EnvExtrasParsingTest.kt new file mode 100644 index 0000000..95999df --- /dev/null +++ b/core/settings/src/test/java/io/github/oustery/opencodephone/core/settings/EnvExtrasParsingTest.kt @@ -0,0 +1,48 @@ +package io.github.oustery.opencodephone.core.settings + +import org.junit.Assert.* +import org.junit.Test + +/** Pure companion parsers of SettingsStore — no Android context needed. */ +class EnvExtrasParsingTest { + @Test fun `parses KEY=value lines, trims and deduplicates keeping the last one`() { + val parsed = SettingsStore.parseEnvExtras( + """ + HTTPS_PROXY=http://127.0.0.1:7890 + # comment line + LANG=C.UTF-8 + + NO_PROXY=github.com + LANG=C.UTF-8 + """.trimIndent(), + ) + assertEquals( + mapOf( + "HTTPS_PROXY" to "http://127.0.0.1:7890", + "LANG" to "C.UTF-8", + "NO_PROXY" to "github.com", + ), + parsed, + ) + } + + @Test fun `skips invalid lines and counts them`() { + val raw = """ + GOOD=1 + no equals sign + 9BAD=2 + =3 + ALSO_GOOD=4 + """.trimIndent() + val parsed = SettingsStore.parseEnvExtras(raw) + assertEquals(mapOf("GOOD" to "1", "ALSO_GOOD" to "4"), parsed) + assertEquals(3, SettingsStore.invalidEnvExtrasCount(raw)) + assertEquals(0, SettingsStore.invalidEnvExtrasCount("")) + assertEquals(0, SettingsStore.invalidEnvExtrasCount("# only a comment")) + } + + @Test fun `value may contain equals signs and spaces are trimmed`() { + val parsed = SettingsStore.parseEnvExtras(" TOKEN = a=b=c ") + assertEquals(mapOf("TOKEN" to "a=b=c"), parsed) + } +}