Implement Rust-owned Android local and HTTPS onboarding (#95)
Android ARM verification / rust (push) Failing after 46m9s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-09 11:31:50 +02:00
1 parent 66b1d4a4c4
commit 36e5f55283
46 files changed
+3002 -79

No files matched your search

+1
View File
@@ -281,4 +281,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm:9.9=androidLintTool
org.slf4j:slf4j-api:2.0.7=ktlint
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
@@ -0,0 +1,360 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import javax.inject.Inject
/** Secrets survive configuration changes in memory, never in a saved-state Bundle. */
@HiltViewModel
class SetupFormViewModel @Inject constructor() : ViewModel() {
var passphrase by mutableStateOf("")
var armor by mutableStateOf("")
var token by mutableStateOf("")
fun clearSecrets() {
passphrase = ""
armor = ""
token = ""
}
}
@Composable
fun OnboardingScreen(
repository: OnboardingRepository,
secrets: SetupFormViewModel,
modifier: Modifier = Modifier
) {
val state by repository.setup.collectAsStateWithLifecycle()
var step by rememberSaveable { mutableStateOf("choice") }
var clone by rememberSaveable { mutableStateOf(false) }
var userId by rememberSaveable { mutableStateOf("") }
var server by rememberSaveable { mutableStateOf("") }
var account by rememberSaveable { mutableStateOf("") }
var path by rememberSaveable { mutableStateOf("") }
var key by rememberSaveable { mutableStateOf("") }
var branch by rememberSaveable { mutableStateOf("") }
var reuse by rememberSaveable { mutableStateOf(false) }
val selectedKey = key.ifEmpty { state.selectedKey }
val selectedBranch = branch.ifEmpty { state.selectedBranch }
// A restarted process reconstructs prepared keys; transport secrets must be entered again.
val page = when {
state.connected && step == "remote" -> "ready"
state.configured && step !in setOf("remote", "token") -> "ready"
state.branches.isNotEmpty() -> "branch"
else -> step
}
Column(
modifier.widthIn(max = 640.dp).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
state.title,
modifier = Modifier.semantics {
heading()
},
style = MaterialTheme.typography.headlineSmall
)
Text(
state.detail,
color = if (state.failure) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
)
if (state.busy) {
CircularProgressIndicator()
TextButton(onClick = repository::cancel) { Text(stringResource(R.string.setup_cancel)) }
} else {
when (page) {
"choice" -> {
Text(stringResource(R.string.setup_manual_alternative))
Button(onClick = {
clone = false
step = "key"
}) { Text(stringResource(R.string.setup_local)) }
Button(onClick = {
clone = true
step = "key"
}) { Text(stringResource(R.string.setup_clone)) }
}
"key" -> {
Text(stringResource(R.string.setup_choose_key))
state.keys.forEach { item ->
Row(
Modifier.selectable(
selected = selectedKey == item.fingerprint,
role = Role.RadioButton,
onClick = { key = item.fingerprint }
)
) {
RadioButton(selected = selectedKey == item.fingerprint, onClick = null)
Column {
Text(item.userIds.joinToString())
Text(item.fingerprint)
}
}
}
if (state.keys.isNotEmpty()) {
if (!clone) {
SecretField(
stringResource(R.string.setup_passphrase),
secrets.passphrase
) {
secrets.passphrase =
it
}
}
Button(onClick = {
if (clone) {
step = "remote"
} else {
repository.createLocal(selectedKey, secrets.passphrase)
secrets.clearSecrets()
}
}) {
Text(
stringResource(
if (clone) R.string.setup_continue else R.string.setup_create
)
)
}
}
Button(onClick = {
step = "generate"
}) { Text(stringResource(R.string.setup_generate)) }
TextButton(onClick = {
step = "import"
}) { Text(stringResource(R.string.setup_import)) }
}
"generate" -> {
Field(stringResource(R.string.setup_user_id), userId, onChange = {
userId = it
})
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
secrets.passphrase =
it
}
Button(onClick = {
repository.generate(userId, secrets.passphrase)
secrets.armor = ""
secrets.token = ""
step =
"key"
}) {
Text(stringResource(R.string.setup_generate))
}
}
"import" -> {
Text(stringResource(R.string.setup_import_help))
SecretField(
stringResource(R.string.setup_armor),
secrets.armor,
singleLine = false
) {
secrets.armor =
it
}
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
secrets.passphrase =
it
}
Button(onClick = {
repository.importKey(secrets.armor, secrets.passphrase)
secrets.armor = ""
secrets.token = ""
step =
"key"
}) {
Text(stringResource(R.string.setup_import))
}
}
"remote" -> {
Field(stringResource(R.string.setup_server), server, KeyboardType.Uri) {
server =
it
}
Field(stringResource(R.string.setup_account), account, onChange = {
account = it
})
Field(stringResource(R.string.setup_repository), path, onChange = { path = it })
SecretField(stringResource(R.string.setup_token), secrets.token) {
secrets.token =
it
}
Button(onClick = {
if (state.configured) {
repository.connect(server, account, path, secrets.token)
} else {
repository.discover(server, account, path, secrets.token)
}
secrets.token = ""
secrets.armor = ""
}) {
Text(
stringResource(
if (state.configured) {
R.string.setup_connect
} else {
R.string.setup_discover
}
)
)
}
}
"branch" -> {
state.branches.forEach { item ->
Row(
Modifier.selectable(
selected = selectedBranch == item,
role = Role.RadioButton,
onClick = { branch = item }
)
) {
RadioButton(selected = selectedBranch == item, onClick = null)
Text(item)
}
}
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
secrets.passphrase =
it
}
Row(
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
reuse =
it
})
) {
Checkbox(checked = reuse, onCheckedChange = null)
Text(stringResource(R.string.setup_reuse))
}
Button(onClick = {
repository.cloneStore(
selectedBranch,
selectedKey,
secrets.passphrase,
reuse
)
secrets.clearSecrets()
}) {
Text(stringResource(R.string.setup_clone))
}
}
"ready" -> {
if (!state.connected) {
Button(onClick = {
step = "remote"
}) { Text(stringResource(R.string.setup_add_remote)) }
} else {
TextButton(onClick = {
step = "token"
}) { Text(stringResource(R.string.setup_replace_token)) }
}
}
"token" -> {
Field(stringResource(R.string.setup_account), account, onChange = {
account = it
})
SecretField(stringResource(R.string.setup_token), secrets.token) {
secrets.token =
it
}
Button(onClick = {
repository.replaceToken(account, secrets.token)
secrets.clearSecrets()
}) { Text(stringResource(R.string.setup_replace_token)) }
}
}
if (page !in setOf("choice", "ready")) {
TextButton(onClick = {
repository.refresh()
secrets.clearSecrets()
step = if (state.configured) "ready" else "choice"
clone = false
}) { Text(stringResource(R.string.setup_back)) }
}
if (state.failure) {
TextButton(onClick = repository::refresh) {
Text(stringResource(R.string.setup_retry))
}
}
}
}
}
@Composable
private fun Field(
label: String,
value: String,
keyboard: KeyboardType = KeyboardType.Text,
onChange: (String) -> Unit
) {
OutlinedTextField(
value,
onChange,
label = {
Text(label)
},
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = keyboard
)
)
}
@Composable
private fun SecretField(
label: String,
value: String,
singleLine: Boolean = true,
onChange: (String) -> Unit
) {
OutlinedTextField(
value,
onChange,
label = {
Text(label)
},
singleLine = singleLine,
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false
),
maxLines = if (singleLine) 1 else 5
)
}
@@ -26,7 +26,7 @@ import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
@Composable
fun ShellScreen(viewModel: ShellViewModel) {
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
Scaffold(
bottomBar = {
@@ -75,13 +75,20 @@ fun ShellScreen(viewModel: ShellViewModel) {
}
)
}
Text(
title,
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.headlineSmall
)
Text(detail)
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
if (!snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
) {
OnboardingScreen(viewModel.onboarding, setupForm)
} else {
Text(
title,
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.headlineSmall
)
Text(detail)
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
}
}
}
}
@@ -2,12 +2,16 @@ package de.rfc1437.ironstorage.features
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellTab
import javax.inject.Inject
@HiltViewModel
class ShellViewModel @Inject constructor(private val repository: ShellRepository) : ViewModel() {
class ShellViewModel @Inject constructor(
private val repository: ShellRepository,
val onboarding: OnboardingRepository
) : ViewModel() {
val snapshot = repository.snapshot
fun selectTab(tab: ShellTab) = repository.selectTab(tab)
@@ -6,4 +6,28 @@
<string name="native_unsupported_device">This device is not supported by this app build.</string>
<string name="native_update_required">The installed app components could not be loaded or do not match. Install a complete update without clearing app data.</string>
<string name="native_operation_failed">The app could not complete this operation. Try again.</string>
<string name="setup_cancel">Cancel setup</string>
<string name="setup_manual_alternative">No server, camera, or biometric enrollment is required for local setup. Import a private key manually or generate one here.</string>
<string name="setup_local">Create local store</string>
<string name="setup_clone">Clone existing store</string>
<string name="setup_choose_key">Select a private GPG encryption key</string>
<string name="setup_passphrase">GPG passphrase</string>
<string name="setup_continue">Continue</string>
<string name="setup_create">Create encrypted local store</string>
<string name="setup_generate">Generate GPG key</string>
<string name="setup_import">Import private GPG key</string>
<string name="setup_user_id">Key name and email</string>
<string name="setup_import_help">Paste one armored private key. It remains in app-private storage; the passphrase is not saved.</string>
<string name="setup_armor">Armored private key</string>
<string name="setup_server">HTTPS server URL</string>
<string name="setup_account">Account</string>
<string name="setup_repository">Repository path (owner/repository)</string>
<string name="setup_token">Application token</string>
<string name="setup_connect">Connect local store</string>
<string name="setup_discover">Discover branches</string>
<string name="setup_reuse">Use matching existing clone</string>
<string name="setup_add_remote">Add HTTPS synchronization</string>
<string name="setup_replace_token">Replace application token</string>
<string name="setup_back">Back to setup choices</string>
<string name="setup_retry">Reload setup state</string>
</resources>