Implement Rust-owned Android local and HTTPS onboarding (#95)
This commit is contained in:
1 parent
66b1d4a4c4
commit
36e5f55283
46 files changed
+3002
-79
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user