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
Expand Up @@ -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
Expand Down Expand Up @@ -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<ServerConfig> = _serverConfiguration
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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) },
Expand All @@ -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))
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading