Implement complete Android settings and recovery controls (#104)
Android ARM verification / rust (push) Failing after 46m46s
Android ARM verification / android (push) Failing after 7s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-10 12:07:28 +02:00
1 parent 06575363a2
commit 43f660ace3
34 files changed
+2720 -64

No files matched your search

@@ -12,12 +12,14 @@ import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.Density
@@ -66,6 +68,7 @@ class AdaptiveShellTest {
original.search,
original.authentication,
original.editor,
original.settings,
saved
)
compose.activity.runOnUiThread {
@@ -101,6 +104,9 @@ class AdaptiveShellTest {
setup.armor = "public fixture armor"
setup.token = "public fixture token"
}
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").performClick()
compose.runOnIdle {
assertEquals("", setup.passphrase)
@@ -118,7 +124,13 @@ class AdaptiveShellTest {
compose.onNodeWithTag("shell-tab-Preferences").performClick()
compose.onNodeWithText("System default").assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").performClick()
compose.runOnIdle {
assertEquals("Preferences", saved.get<String>("shell.tab"))
@@ -141,6 +153,7 @@ class AdaptiveShellTest {
original.search,
original.authentication,
original.editor,
original.settings,
SavedStateHandle(
saved.keys().associateWith {
saved.get<Any>(it)
@@ -162,6 +175,9 @@ class AdaptiveShellTest {
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
compose.onNodeWithText("Dark").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(hasTestTag("shell-open-appearance"))
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
}
@@ -177,6 +193,7 @@ class AdaptiveShellTest {
original.search,
original.authentication,
original.editor,
original.settings,
saved
)
compose.activity.runOnUiThread {
@@ -219,6 +236,7 @@ class AdaptiveShellTest {
original.search,
original.authentication,
original.editor,
original.settings,
SavedStateHandle(mapOf("shell.tab" to "Passwords"))
)
compose.activity.runOnUiThread {
@@ -0,0 +1,222 @@
package de.rfc1437.ironstorage
import android.os.Bundle
import android.os.Parcel
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.KeyTransferState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.core.ProviderStatus
import de.rfc1437.ironstorage.core.SettingsPage
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.SettingsState
import de.rfc1437.ironstorage.core.TransferFrame
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SettingsScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class SettingsScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun searchableSettingsKeepEditsAndUseExplicitSecretAndProviderActionsAtLargeFont() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val repository = Fixture()
val authentication = object : AuthenticationRepository by original.authentication {
override val state = MutableStateFlow(AuthenticationState())
override val presentation = ProtectedPresentation()
override fun reload() = Unit
override fun lock() {
state.value = AuthenticationState()
}
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent(authentication.presentation) {
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
ApplicationTheme(authentication) {
SettingsScreen(repository, authentication, original.onboarding, setup) {}
}
}
}
}
fun search(text: String) {
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(androidx.compose.ui.test.hasTestTag("settings-search"))
compose.onNodeWithTag("settings-search").performTextReplacement(text)
}
fun button(text: String) {
compose.onNodeWithTag("settings-list").performScrollToNode(hasText(text))
compose.onNodeWithText(text).assertIsDisplayed().performClick()
}
search("Git")
button("Edit Git author")
compose.onNodeWithTag("settings-git-name").performTextReplacement("New public author")
compose.onNodeWithTag("settings-git-email").performTextReplacement("public@example.test")
compose.onNodeWithText("Save").performClick()
assertEquals(listOf("New public author", "public@example.test"), repository.identity)
compose.runOnIdle {
repository.state.value =
repository.state.value.copy(error = "Typed validation failure")
}
compose.onNodeWithTag(
"settings-dialog-error"
).performScrollTo().assertTextContains("Typed validation failure").assertIsDisplayed()
compose.onNodeWithTag("settings-git-name").assertTextContains("New public author")
compose.onNodeWithText("Cancel").performClick()
search("timeout")
button("Edit inactivity timeout")
compose.onNodeWithTag("settings-timeout").performTextReplacement("invalid candidate")
compose.onNodeWithText("Save").performClick()
assertEquals("invalid candidate", repository.timeoutInput)
compose.onNodeWithText("Cancel").performClick()
search("token")
button("Remove application token")
assertEquals(0, repository.removals)
compose.onNodeWithText("Save").performClick()
assertEquals(1, repository.removals)
compose.onNodeWithText("Cancel").performClick()
search("Autofill")
compose.onNodeWithTag(
"settings-list"
).performScrollToNode(
hasText("The IronStorage provider service is not included in this build")
)
compose.onAllNodes(
hasText("The IronStorage provider service is not included in this build")
)[0].assertIsDisplayed()
assertFalse(repository.state.value.page!!.autofill.enabled)
search("privacy")
button("MIT license")
compose.onNodeWithText("Done").assertIsDisplayed().performClick()
assertTrue(repository.reloads > 0)
search("GPG")
button("Import or export GPG QR codes")
compose.runOnIdle {
repository.state.value = repository.state.value.copy(
transfer = repository.state.value.transfer.copy(
frames = listOf(TransferFrame(1u, 1u, 21u, ByteArray(21 * 21) { 1 }))
)
)
}
compose.onNodeWithContentDescription("QR frame 1 of 1")
.performScrollTo().assertWidthIsAtLeast(280.dp).assertHeightIsAtLeast(280.dp)
.assertIsDisplayed()
compose.onNodeWithText("Choose QR images").performScrollTo().performClick()
assertTrue(repository.choseImages)
val markers = listOf("private-export-passphrase-marker", "private-qr-payload-marker")
compose.onNodeWithText(
"GPG passphrase"
).performScrollTo().performTextReplacement(markers[0])
compose.onNodeWithText(
"QR payload or armored GPG key"
).performScrollTo().performTextReplacement(markers[1])
compose.runOnIdle {
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
for (marker in markers) {
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
}
} finally {
parcel.recycle()
}
authentication.presentation.clear()
}
}
private class Fixture : SettingsRepository {
override val state = MutableStateFlow(
SettingsState(
page = SettingsPage(
"Public fixture store",
"https://example.test/store.git",
"example.test",
"server-fixture",
"public-account",
"Fixture GPG key",
"PUBLIC-FINGERPRINT",
"Original author",
"original@example.test",
300u,
"No TOTP discovery cache.", false,
ProviderStatus(
true,
false,
false
),
ProviderStatus(true, false, false), "fixture-version"
)
)
)
var identity = emptyList<String>()
var timeoutInput = ""
var removals = 0
var reloads = 0
var choseImages = false
override fun reload() {
reloads += 1
}
override fun setDefaultKey(fingerprint: String) = Unit
override fun setGitIdentity(name: String, email: String) {
identity = listOf(name, email)
}
override fun setTimeout(seconds: String) {
timeoutInput = seconds
}
override fun removeToken() {
removals += 1
}
override fun rebuildCache(passphrase: String) = Unit
override fun openAutofill() = Unit
override fun openCredentials() = Unit
override fun beginTransfer() {
state.value =
state.value.copy(error = null, transfer = KeyTransferState(open = true))
}
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) = Unit
override fun chooseQrImages() {
choseImages = true
}
override fun addQrImages(uris: List<String>) = Unit
override fun addQrPayload(payload: String) = Unit
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = Unit
override fun closeTransfer() {
state.value = state.value.copy(transfer = KeyTransferState())
}
override fun cancel() = Unit
}
}
@@ -25,12 +25,14 @@ import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.ProtectedContent
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
import javax.inject.Inject
@AndroidEntryPoint
@@ -43,6 +45,10 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var onboarding: OnboardingRepository
@Inject lateinit var settingsPlatform: AndroidSettingsPlatform
@Inject lateinit var settings: SettingsRepository
private val setupForm: SetupFormViewModel by viewModels()
private val viewModel: ShellViewModel by viewModels()
@@ -105,6 +111,7 @@ class MainActivity : FragmentActivity() {
cleanup?.close()
unregisterReceiver(protectedState)
keystore.detach(this)
settingsPlatform.detach(this)
super.onDestroy()
}
@@ -136,6 +143,7 @@ class MainActivity : FragmentActivity() {
return
}
keystore.attach(this)
settingsPlatform.attach(this, settings::addQrImages)
enableEdgeToEdge()
setContent {
ProtectedContent(authentication.presentation) {
@@ -17,12 +17,16 @@ import de.rfc1437.ironstorage.core.NativeOnboardingRepository
import de.rfc1437.ironstorage.core.NativePasswordRepository
import de.rfc1437.ironstorage.core.NativePrivateSession
import de.rfc1437.ironstorage.core.NativeSearchRepository
import de.rfc1437.ironstorage.core.NativeSettingsRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.SearchRepository
import de.rfc1437.ironstorage.core.SettingsPlatform
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
import de.rfc1437.ironstorage.platform.privateDirectories
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
@@ -32,6 +36,29 @@ import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object ShellModule {
@Provides
@Singleton
fun androidSettingsPlatform(
@ApplicationContext context: Context,
keystore: AndroidKeystoreAdapter
): AndroidSettingsPlatform = AndroidSettingsPlatform(context, keystore)
@Provides
fun settingsPlatform(platform: AndroidSettingsPlatform): SettingsPlatform = platform
@Provides
@Singleton
fun settingsRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
platform: SettingsPlatform
): SettingsRepository = NativeSettingsRepository(
session,
authentication,
platform,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
@Provides
@Singleton
fun editorRepository(
@@ -0,0 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
9M+959XNJUJvYiBGaXh0dXJlIDxib2JAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQT
FggAMQUCanjntBYhBLNwJ7VvxAa9P2piKyrANJK5ktBvAhsDAh4BAwsJBwIVCAEW
AScCGQEACgkQKsA0krmS0G9BCwD/daZy2BdmNQX8TldPBtoLTrugeaFmtrcVDQZ3
AzWEMcMA/16q87Zkaw2ozanLSBcRiq4iZnBTAmU/7bUSlA2myZoHzjgEZZIAgBIK
KwYBBAGXVQEFAQEHQByJpci1aMxEcUVlw9mByZUPyaQ7kI1emdJOuN1hcLEKAwEI
B8J4BBgWCAAgBQJqeOe1AhsMFiEEs3AntW/EBr0/amIrKsA0krmS0G8ACgkQKsA0
krmS0G+2lwEAvD9R7o52H37GsfeMS15UJBeIHBaYHjpsIyfoOWq0vsEA/j0oL4IG
SIakL3z1C1KHU+4iz3rzz2vNnhgGQd7+DS8H
=vS0q
-----END PGP PUBLIC KEY BLOCK-----
@@ -1,6 +1,7 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
@@ -29,9 +30,10 @@ class NativeEntryViewerTest {
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val keystore = MemoryKeystore()
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, MemoryKeystore())
}, keystore)
withContext(Dispatchers.IO) { session.initialize() }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val clipboard = MemoryClipboard()
@@ -250,6 +252,13 @@ class NativeEntryViewerTest {
scope,
password
)
settingsUseTheSameNativeOwnerAndProtectedTransfer(
session,
authentication,
scope,
password,
keystore
)
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel()
@@ -364,8 +373,8 @@ class NativeEntryViewerTest {
authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { editor.state.first { it.locked } }
assertTrue(editor.state.value.fields.isEmpty())
assertNull(request { editor.resume(passphrase) }.error)
authentication.authenticated { it.setAuthenticationTimeout(300u) }
assertNull(request { editor.resume(passphrase) }.error)
request { editor.requestClose() }
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
assertFalse(editor.state.value.open)
@@ -373,6 +382,137 @@ class NativeEntryViewerTest {
withContext(Dispatchers.Main.immediate) { authentication.lock() }
}
}
private suspend fun settingsUseTheSameNativeOwnerAndProtectedTransfer(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
scope: CoroutineScope,
passphrase: String,
keystore: MemoryKeystore
) {
var image = ByteArray(0)
val platform = object : SettingsPlatform {
override fun autofill() = ProviderStatus(true, false, false)
override fun credentials() =
ProviderStatus(android.os.Build.VERSION.SDK_INT >= 34, false, false)
override fun biometricsAvailable() = false
override fun version() = "native-settings-fixture"
override fun openAutofill() = true
override fun openCredentials() = true
override fun chooseQrImages() = true
override suspend fun readQrImage(uri: String) = image.copyOf()
}
val settings = NativeSettingsRepository(session, authentication, platform, scope)
suspend fun request(action: () -> Unit): SettingsState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { settings.state.first { !it.busy } }
}
val initial = request { settings.reload() }
assertNull(initial.error)
assertEquals("native-settings-fixture", initial.page!!.version)
assertFalse(initial.page.biometricAvailable)
assertNull(
request {
settings.setGitIdentity("Android settings author", "android-settings@example.test")
}.error
)
assertEquals("Android settings author", settings.state.value.page!!.gitName)
assertNotNull(request { settings.setGitIdentity("candidate", "invalid\nemail") }.error)
assertEquals("Android settings author", settings.state.value.page!!.gitName)
assertNull(request { settings.setTimeout("45") }.error)
assertEquals(45uL, settings.state.value.page!!.timeout)
assertNotNull(request { settings.setTimeout("0") }.error)
assertEquals(45uL, settings.state.value.page!!.timeout)
assertNotNull(request { settings.removeToken() }.error)
assertNull(settings.state.value.page!!.account)
assertNull(request { settings.rebuildCache(passphrase) }.error)
assertTrue(settings.state.value.page!!.cacheDetail.contains("current TOTP entries"))
val beforeEnrollment = keystore.keyCount
authentication.authenticated { it.enrollBiometricUnlock(passphrase) }
assertTrue(authentication.state.value.biometricEnabled)
assertEquals(beforeEnrollment + 1, keystore.keyCount)
withContext(Dispatchers.IO) {
session.storage().androidHomeOperation(
session.backend(),
object : MobileClock {
override fun monotonicMilliseconds(): ULong =
android.os.SystemClock.elapsedRealtime().toULong()
}
).use { home ->
assertEquals("Android settings author", home.identity().name)
home.cached()
}
}
assertTrue(authentication.authenticated { it.state().unlocked })
keystore.invalidated = true
authentication.authenticated { it.setBiometricUnlock(false) }
assertFalse(authentication.state.value.biometricEnabled)
assertFalse(authentication.state.value.unlocked)
assertEquals(beforeEnrollment, keystore.keyCount)
keystore.invalidated = false
assertNull(request { settings.beginTransfer() }.error)
val fingerprint = settings.state.value.transfer.keys.first { it.privateKey }.fingerprint
assertNotNull(request { settings.exportKey(fingerprint, true, "incorrect") }.error)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
val frame = settings.state.value.transfer.frames.single()
val size = (frame.width.toInt() + 8) * 4
val pixels = IntArray(size * size) { android.graphics.Color.WHITE }
for (row in 0 until frame.width.toInt()) {
for (column in 0 until frame.width.toInt()) {
if (frame.modules[row * frame.width.toInt() + column].toInt() == 1) {
for (dy in 0 until 4) {
for (dx in 0 until 4) {
pixels[((row + 4) * 4 + dy) * size + (column + 4) * 4 + dx] =
android.graphics.Color.BLACK
}
}
}
}
}
val bitmap = android.graphics.Bitmap.createBitmap(
pixels,
size,
size,
android.graphics.Bitmap.Config.ARGB_8888
)
val output = java.io.ByteArrayOutputStream()
assertTrue(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output))
bitmap.recycle()
image = output.toByteArray()
request { settings.closeTransfer() }
assertTrue(frame.modules.all { it.toInt() == 0 })
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
assertNotNull(request { settings.importKey("", false, false) }.error)
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
request { settings.closeTransfer() }
assertNull(request { settings.beginTransfer() }.error)
val armor = InstrumentationRegistry.getInstrumentation().context.assets.open(
"bob-public.asc"
).bufferedReader().use {
it.readText()
}
assertNull(request { settings.addQrPayload(armor) }.error)
assertFalse(settings.state.value.transfer.ready!!.privateKey)
assertNull(request { settings.importKey("", false, false) }.error)
assertFalse(settings.state.value.transfer.open)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
val bob = settings.state.value.transfer.keys.single { it.title.contains("Bob") }
assertNotNull(request { settings.setDefaultKey(bob.fingerprint) }.error)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
assertNull(request { settings.beginTransfer() }.error)
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() }
assertFalse(settings.state.value.transfer.open)
assertTrue(settings.state.value.transfer.frames.isEmpty())
assertNull(request { settings.setDefaultKey(fingerprint) }.error)
assertFalse(authentication.state.value.unlocked)
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
request { settings.setTimeout("300") }
}
private class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this
@@ -387,8 +527,10 @@ class NativeEntryViewerTest {
}
private class MemoryKeystore : AndroidKeystore {
private val keys = mutableSetOf<String>()
val keyCount get() = keys.size
var invalidated = false
override fun deviceState() =
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false)
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, true, false)
override fun createKey(specification: KeystoreKeySpecification) {
keys.add(specification.alias)
}
@@ -412,6 +554,9 @@ class NativeEntryViewerTest {
aad: ByteArray,
value: KeystoreCiphertext
): ByteArray {
if (invalidated && authorization == KeystoreAuthorization.StrongBiometric) {
throw KeystoreException(KeystoreFailure.Invalidated)
}
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
val record =
ciphertexts[value.bytes.toList()]
@@ -0,0 +1,323 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferKind
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferProgress
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileKeyTransfer
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeSettingsRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
private val platform: SettingsPlatform,
private val scope: CoroutineScope
) : SettingsRepository {
private val mutable = MutableStateFlow(SettingsState())
override val state: StateFlow<SettingsState> = mutable
private val operation = Mutex()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
private var transfer: MobileKeyTransfer? = null
private var importer: MobileKeyTransferImport? = null
init {
val registration = authentication.presentation.register {
if (mutable.value.transfer.open) closeTransfer()
}
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
}
private suspend fun page(): SettingsPage {
val values = authentication.authenticated {
Triple(it.preferences(), it.gitIdentity(), it.totpCacheStatus())
}
val (preferences, identity, cache) = values
return SettingsPage(
preferences.repositoryTitle, preferences.repositoryUrl,
preferences.serverTitle, preferences.serverIdentity, preferences.applicationAccount,
preferences.defaultKeyTitle, preferences.defaultKeyFingerprint,
identity.name, identity.email, preferences.authenticationTimeoutSeconds,
cache.detail, platform.biometricsAvailable(), platform.autofill(),
platform.credentials(), platform.version()
)
}
override fun reload() = run { mutable.value = mutable.value.copy(page = page()) }
override fun setDefaultKey(fingerprint: String) = run {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
releaseTransfer()
authentication.authenticated { it.setDefaultKey(fingerprint) }
authentication.lock()
mutable.value = mutable.value.copy(page = page(), notice = "Default GPG key updated.")
}
override fun setGitIdentity(name: String, email: String) = run {
authentication.authenticated { it.setGitIdentity(name, email) }
mutable.value = mutable.value.copy(page = page(), notice = "Git author updated.")
}
override fun setTimeout(seconds: String) = run {
authentication.authenticated { it.setAuthenticationTimeoutText(seconds) }
mutable.value = mutable.value.copy(page = page(), notice = "Inactivity timeout updated.")
}
override fun removeToken() = run {
authentication.authenticated { it.removeApplicationToken() }
mutable.value =
mutable.value.copy(
page = page(),
notice = "Application token removed from protected storage."
)
}
override fun rebuildCache(passphrase: String) = run {
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
try {
val result = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.rebuildTotpCache(active)
}
mutable.value =
mutable.value.copy(
page = page(),
notice =
result.cacheNotice ?: "TOTP cache rebuilt."
)
} finally {
discovery = null
active.close()
}
}
override fun openAutofill() {
if (!platform.openAutofill()) {
mutable.value =
mutable.value.copy(
error = "Autofill provider settings are unavailable on this device."
)
}
}
override fun openCredentials() {
if (!platform.openCredentials()) {
mutable.value =
mutable.value.copy(
error = "Credential provider settings are unavailable on this device."
)
}
}
override fun beginTransfer() = run { ticket ->
withContext(Dispatchers.IO) {
session.storage()
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
}
val keys = withContext(Dispatchers.IO) { requireNotNull(transfer).keys() }
if (ticket ==
epoch
) {
mutable.value = mutable.value.copy(
transfer = KeyTransferState(
open = true,
keys = keys.map {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
}
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) =
run { ticket ->
val result = withContext(Dispatchers.IO) {
requireNotNull(transfer).export(
fingerprint,
if (privateKey) MobileKeyTransferKind.PRIVATE else MobileKeyTransferKind.PUBLIC,
passphrase.ifEmpty { null }
)
}
if (ticket ==
epoch
) {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(
transfer = mutable.value.transfer.copy(
frames = result.frames.map {
TransferFrame(it.sequence, it.total, it.width, it.modules)
}
)
)
} else {
result.frames.forEach { it.modules.fill(0) }
}
}
private fun progress(ticket: Long, progress: MobileKeyTransferProgress) {
if (ticket != epoch) return
mutable.value = mutable.value.copy(
transfer = mutable.value.transfer.copy(
received = progress.received,
total = progress.total,
ready = progress.key?.let {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
override fun chooseQrImages() {
if (!platform.chooseQrImages()) {
mutable.value =
mutable.value.copy(error = "The image picker is unavailable on this device.")
}
}
override fun addQrImages(uris: List<String>) {
scope.launch {
mutable.first { !it.busy }
run { ticket ->
if (importer == null) {
val keys = withContext(Dispatchers.IO) {
session.storage()
transfer = mobileKeyTransfer()
importer = requireNotNull(transfer).importer()
requireNotNull(transfer).keys()
}
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
transfer = KeyTransferState(
open = true,
keys = keys.map {
TransferKey(
it.fingerprint,
it.title,
it.kind == MobileKeyTransferKind.PRIVATE
)
}
)
)
}
}
for (uri in uris) {
if (ticket != epoch) break
val bytes = withContext(Dispatchers.IO) { platform.readQrImage(uri) }
try {
progress(
ticket,
withContext(Dispatchers.IO) {
requireNotNull(importer).addImage(bytes)
}
)
} finally {
bytes.fill(0)
}
}
}
}
}
override fun addQrPayload(payload: String) = run { ticket ->
progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) })
}
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run {
val result = withContext(Dispatchers.IO) {
requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite)
}
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
releaseTransfer()
authentication.lock()
withContext(Dispatchers.IO) { session.storage().keyMaterialChanged() }
mutable.value =
mutable.value.copy(page = page(), notice = result.title + "\n" + result.detail)
}
private fun releaseTransfer() {
importer?.close()
transfer?.close()
importer = null
transfer = null
}
override fun closeTransfer() {
epoch += 1
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false)
scope.launch { operation.withLock { releaseTransfer() } }
}
override fun cancel() {
epoch += 1
discovery?.cancel()
closeTransfer()
}
private fun run(action: suspend (Long) -> Unit) {
if (mutable.value.busy) return
val ticket = epoch
mutable.value = mutable.value.copy(busy = true, error = null, notice = null)
scope.launch {
operation.withLock {
if (ticket != epoch) return@withLock
try {
action(ticket)
if (ticket != epoch) {
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
mutable.value = mutable.value.copy(transfer = KeyTransferState())
}
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (error: MobileKeyTransferFfiException.Failed) {
if (ticket == epoch) mutable.value = mutable.value.copy(error = error.message)
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
error =
"Private storage recovery required. " +
"Unlock the device and reload; " +
"missing or invalidated Keystore data requires " +
"reimporting the GPG key."
)
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "Update the application and retry.")
}
} catch (_: java.io.IOException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = "The selected QR image could not be read.")
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
}
}
}
}
}
@@ -0,0 +1,79 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class ProviderStatus(val supported: Boolean, val installed: Boolean, val enabled: Boolean)
interface SettingsPlatform {
fun autofill(): ProviderStatus
fun credentials(): ProviderStatus
fun biometricsAvailable(): Boolean
fun version(): String
fun openAutofill(): Boolean
fun openCredentials(): Boolean
fun chooseQrImages(): Boolean
suspend fun readQrImage(uri: String): ByteArray
}
data class SettingsPage(
val repositoryTitle: String,
val repositoryUrl: String,
val serverTitle: String,
val serverIdentity: String,
val account: String?,
val defaultKeyTitle: String,
val defaultKeyFingerprint: String,
val gitName: String,
val gitEmail: String,
val timeout: ULong,
val cacheDetail: String,
val biometricAvailable: Boolean,
val autofill: ProviderStatus,
val credentials: ProviderStatus,
val version: String
)
data class TransferKey(val fingerprint: String, val title: String, val privateKey: Boolean)
data class TransferFrame(
val sequence: UInt,
val total: UInt,
val width: UInt,
val modules: ByteArray
) {
override fun toString() = "TransferFrame([REDACTED])"
}
data class KeyTransferState(
val open: Boolean = false,
val keys: List<TransferKey> = emptyList(),
val frames: List<TransferFrame> = emptyList(),
val received: UInt = 0u,
val total: UInt = 0u,
val ready: TransferKey? = null
) {
override fun toString() = "KeyTransferState([REDACTED])"
}
data class SettingsState(
val busy: Boolean = false,
val page: SettingsPage? = null,
val error: String? = null,
val notice: String? = null,
val transfer: KeyTransferState = KeyTransferState()
) {
override fun toString() = "SettingsState(busy=$busy, [REDACTED])"
}
interface SettingsRepository {
val state: StateFlow<SettingsState>
fun reload()
fun setDefaultKey(fingerprint: String)
fun setGitIdentity(name: String, email: String)
fun setTimeout(seconds: String)
fun removeToken()
fun rebuildCache(passphrase: String)
fun openAutofill()
fun openCredentials()
fun beginTransfer()
fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String)
fun chooseQrImages()
fun addQrImages(uris: List<String>)
fun addQrPayload(payload: String)
fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean)
fun closeTransfer()
fun cancel()
}
@@ -28,10 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
@Composable
fun AuthenticationControls(repository: AuthenticationRepository) {
fun AuthenticationControls(repository: AuthenticationRepository, reload: Boolean = true) {
val state by repository.state.collectAsStateWithLifecycle()
var enrollment by remember { mutableStateOf(false) }
LaunchedEffect(repository) { repository.reload() }
LaunchedEffect(repository, reload) { if (reload) repository.reload() }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.auth_title))
@@ -340,7 +340,7 @@ private fun Field(
}
@Composable
private fun SecretField(
internal fun SecretField(
label: String,
value: String,
singleLine: Boolean = true,
@@ -0,0 +1,600 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ProviderStatus
import de.rfc1437.ironstorage.core.SettingsRepository
@Composable
fun SettingsScreen(viewModel: ShellViewModel, setup: SetupFormViewModel) {
SettingsScreen(
viewModel.settings,
viewModel.authentication,
viewModel.onboarding,
setup,
viewModel::openAppearance
)
}
@Composable
fun SettingsScreen(
repository: SettingsRepository,
authentication: AuthenticationRepository,
onboarding: OnboardingRepository,
setup: SetupFormViewModel,
openAppearance: () -> Unit
) {
val state by repository.state.collectAsStateWithLifecycle()
val authenticationState by authentication.state.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var edit by remember { mutableStateOf<String?>(null) }
var showSetup by remember { mutableStateOf(false) }
var licenses by remember { mutableStateOf(false) }
val uri = LocalUriHandler.current
val setupState by onboarding.setup.collectAsStateWithLifecycle()
LaunchedEffect(setupState.busy, setupState.connected, setupState.selectedKey) {
if (!setupState.busy) repository.reload()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.reload() }
DisposableEffect(repository) { onDispose { repository.cancel() } }
val headings = listOf(
"store" to stringResource(R.string.settings_store),
"git" to stringResource(R.string.settings_git),
"auth" to stringResource(R.string.settings_auth),
"cache" to stringResource(R.string.settings_cache),
"providers" to stringResource(R.string.settings_providers),
"recovery" to stringResource(R.string.settings_recovery),
"about" to stringResource(R.string.settings_about)
)
val matches = headings.filter { (_, title) -> title.contains(query, ignoreCase = true) }
ProtectedInputSurface(authentication.presentation)
LazyColumn(
Modifier.fillMaxSize().testTag("settings-list"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
stringResource(R.string.settings_title),
style = MaterialTheme.typography.headlineSmall
)
OutlinedTextField(
query,
{
query = it
},
label = { Text(stringResource(R.string.settings_search)) },
modifier = Modifier.fillMaxWidth().testTag("settings-search"),
singleLine = true
)
if (state.busy) {
CircularProgressIndicator()
TextButton(onClick = repository::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.notice?.let { Text(it) }
TextButton(onClick = repository::reload) {
Text(stringResource(R.string.settings_reload))
}
}
for ((id, title) in matches) {
item(key = id) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.semantics {
heading()
}
)
val page = state.page
when (id) {
"store" -> {
page?.let {
Text(it.repositoryTitle)
Text(it.repositoryUrl)
Text(it.serverTitle)
Text(it.serverIdentity)
Text(it.defaultKeyTitle)
Text(it.defaultKeyFingerprint)
Text(it.account ?: stringResource(R.string.settings_no_token))
}
Text(stringResource(R.string.settings_token_hidden))
TextButton(onClick = {
showSetup = !showSetup
}) { Text(stringResource(R.string.settings_remote_token)) }
TextButton(enabled = !state.busy && page?.account != null, onClick = {
edit =
"remove"
}) {
Text(stringResource(R.string.settings_remove_token))
}
TextButton(enabled = !state.busy, onClick = repository::beginTransfer) {
Text(stringResource(R.string.settings_key_transfer))
}
if (showSetup) {
OnboardingScreen(
onboarding,
setup,
scroll = false
)
}
}
"git" -> {
page?.let {
Text(it.gitName)
Text(it.gitEmail)
}
TextButton(enabled = !state.busy && page != null, onClick = {
edit = "git"
}) { Text(stringResource(R.string.settings_edit_git)) }
}
"auth" -> {
page?.let {
Text(
stringResource(
if (it.biometricAvailable) {
R.string.settings_biometry_available
} else {
R.string.settings_biometry_unavailable
}
)
)
Text(
stringResource(
R.string.settings_timeout_value,
it.timeout.toString()
)
)
}
TextButton(enabled = !state.busy && page != null, onClick = {
edit =
"timeout"
}) { Text(stringResource(R.string.settings_edit_timeout)) }
AuthenticationControls(authentication, reload = false)
TextButton(onClick = authentication::lock) {
Text(stringResource(R.string.settings_lock_now))
}
TextButton(onClick = {
setup.clearSecrets()
openAppearance()
}, modifier = Modifier.testTag("shell-open-appearance")) {
Text(stringResource(R.string.shell_appearance))
}
}
"cache" -> {
page?.let { Text(it.cacheDetail) }
TextButton(enabled = !state.busy && page != null, onClick = {
if (authenticationState.unlocked) {
repository.rebuildCache("")
} else {
edit = "cache"
}
}) { Text(stringResource(R.string.settings_rebuild)) }
}
"providers" -> {
page?.let {
ProviderSetting(
stringResource(R.string.settings_autofill),
it.autofill,
repository::openAutofill
)
ProviderSetting(
stringResource(R.string.settings_credentials),
it.credentials,
repository::openCredentials
)
}
}
"recovery" -> {
Text(stringResource(R.string.settings_recovery_key))
Text(stringResource(R.string.settings_recovery_store))
Text(stringResource(R.string.settings_recovery_cache))
Text(stringResource(R.string.settings_offline))
}
"about" -> {
page?.let {
Text(stringResource(R.string.settings_version, it.version))
}
Text(stringResource(R.string.settings_privacy))
TextButton(onClick = {
licenses = true
}) { Text(stringResource(R.string.settings_licenses)) }
TextButton(onClick = {
uri.openUri("https://git.rfc1437.de/hugo/IronStorage/issues")
}) { Text(stringResource(R.string.settings_support)) }
TextButton(onClick = {
uri.openUri(
"https://git.rfc1437.de/hugo/IronStorage/" +
"src/branch/main/android/dependency-policy.json"
)
}) {
Text(stringResource(R.string.settings_dependency_licenses))
}
}
}
}
}
}
}
edit?.let { action ->
if (action ==
"cache"
) {
PassphraseDialog(
stringResource(R.string.settings_rebuild),
stringResource(R.string.settings_rebuild_detail),
authentication.presentation,
onCancel = { edit = null },
onSubmit = {
edit =
null
repository.rebuildCache(it)
}
)
} else {
SettingsEditDialog(repository, authentication, action) { edit = null }
}
}
if (state.transfer.open) KeyTransferDialog(repository, authentication)
if (licenses) {
val resources = LocalResources.current
val text =
remember(resources) {
resources.openRawResource(R.raw.license).bufferedReader().use {
it.readText()
}
}
AlertDialog(
onDismissRequest = {
licenses = false
},
title = { Text(stringResource(R.string.settings_licenses)) },
text = { Text(text, modifier = Modifier.verticalScroll(rememberScrollState())) },
confirmButton = {
TextButton(onClick = {
licenses = false
}) { Text(stringResource(R.string.settings_done)) }
}
)
}
}
@Composable
private fun ProviderSetting(title: String, status: ProviderStatus, open: () -> Unit) {
Text(title)
Text(
stringResource(
when {
!status.supported -> R.string.settings_provider_unsupported
!status.installed -> R.string.settings_provider_not_installed
status.enabled -> R.string.settings_provider_enabled
else -> R.string.settings_provider_disabled
}
)
)
TextButton(onClick = open) { Text(stringResource(R.string.settings_provider_open)) }
}
@Composable
private fun SettingsEditDialog(
repository: SettingsRepository,
authentication: AuthenticationRepository,
action: String,
close: () -> Unit
) {
val state by repository.state.collectAsStateWithLifecycle()
var name by rememberProtectedInput(authentication.presentation)
var email by rememberProtectedInput(authentication.presentation)
var submitted by remember { mutableStateOf(false) }
var timeout by rememberProtectedInput(authentication.presentation)
LaunchedEffect(action) {
name = state.page?.gitName.orEmpty()
email =
state.page?.gitEmail.orEmpty()
timeout = state.page?.timeout?.toString().orEmpty()
}
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
Text(
stringResource(
when (action) {
"git" -> R.string.settings_edit_git
"timeout" -> R.string.settings_edit_timeout
else -> R.string.settings_remove_token
}
)
)
},
text = {
Column(
Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ProtectedInputSurface(authentication.presentation)
when (action) {
"git" -> {
OutlinedTextField(name, {
name = it
}, label = {
Text(stringResource(R.string.settings_git_name))
}, modifier = Modifier.testTag("settings-git-name"))
OutlinedTextField(email, {
email = it
}, label = {
Text(stringResource(R.string.settings_git_email))
}, modifier = Modifier.testTag("settings-git-email"))
}
"timeout" -> OutlinedTextField(timeout, {
timeout = it
}, label = {
Text(stringResource(R.string.settings_timeout_seconds))
}, modifier = Modifier.testTag("settings-timeout"))
else -> Text(stringResource(R.string.settings_remove_detail))
}
state.error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("settings-dialog-error")
)
}
}
},
confirmButton = {
TextButton(enabled = !state.busy, onClick = {
submitted = true
when (action) {
"git" -> repository.setGitIdentity(name, email)
"timeout" -> repository.setTimeout(timeout)
else -> repository.removeToken()
}
}) { Text(stringResource(R.string.settings_save)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.auth_cancel)) }
}
)
LaunchedEffect(state.notice, state.busy) {
if (submitted && state.notice != null &&
!state.busy
) {
close()
}
}
}
@Composable
private fun KeyTransferDialog(
repository: SettingsRepository,
authentication: AuthenticationRepository
) {
val state by repository.state.collectAsStateWithLifecycle()
var fingerprint by remember {
mutableStateOf(state.transfer.keys.firstOrNull()?.fingerprint.orEmpty())
}
var privateKey by remember { mutableStateOf(false) }
var makeDefault by remember { mutableStateOf(false) }
var overwrite by remember { mutableStateOf(false) }
var frame by remember { mutableStateOf(0) }
var passphrase by rememberProtectedInput(authentication.presentation)
var payload by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(
securePolicy = SecureFlagPolicy.SecureOn,
usePlatformDefaultWidth = false
),
onDismissRequest = repository::closeTransfer,
title = {
Text(stringResource(R.string.settings_key_transfer))
},
text = {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ProtectedInputSurface(authentication.presentation)
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (state.busy) CircularProgressIndicator()
state.transfer.keys.forEach { key ->
Row(
Modifier.fillMaxWidth().selectable(
selected = fingerprint == key.fingerprint,
enabled = !state.busy,
role = Role.RadioButton,
onClick = { fingerprint = key.fingerprint }
)
) {
RadioButton(selected = fingerprint == key.fingerprint, onClick = null)
Column(Modifier.weight(1f)) {
Text(key.title)
Text(key.fingerprint)
}
}
}
TextButton(
enabled =
!state.busy &&
state.transfer.keys.any {
it.fingerprint == fingerprint && it.privateKey
},
onClick = { repository.setDefaultKey(fingerprint) }
) { Text(stringResource(R.string.settings_default_key_select)) }
Text(stringResource(R.string.settings_private_warning))
SettingsToggle(stringResource(R.string.settings_private_export), privateKey) {
privateKey =
it
}
SecretField(stringResource(R.string.setup_passphrase), passphrase) {
passphrase = it
}
TextButton(enabled = !state.busy && fingerprint.isNotEmpty(), onClick = {
repository.exportKey(fingerprint, privateKey, passphrase)
passphrase = ""
frame =
0
}) { Text(stringResource(R.string.settings_export_confirm)) }
state.transfer.frames.getOrNull(frame)?.let { qr ->
val description =
stringResource(
R.string.settings_qr_frame,
qr.sequence.toString(),
qr.total.toString()
)
Text(description)
Canvas(
Modifier.fillMaxWidth().height(280.dp).semantics {
contentDescription =
description
}
) {
drawRect(Color.White)
val width = qr.width.toInt()
val scale = minOf(size.width, size.height) / (width + 8)
val x = (size.width - scale * width) / 2
val y = (size.height - scale * width) / 2
for (row in 0 until width) {
for (column in 0 until width) {
if (qr.modules[
row *
width +
column
].toInt() ==
1
) {
drawRect(
Color.Black,
Offset(x + column * scale, y + row * scale),
Size(scale, scale)
)
}
}
}
}
FlowRow {
TextButton(enabled = frame > 0, onClick = {
frame -= 1
}) { Text(stringResource(R.string.settings_previous)) }
TextButton(enabled = frame < state.transfer.frames.lastIndex, onClick = {
frame +=
1
}) { Text(stringResource(R.string.settings_next)) }
}
}
TextButton(enabled = !state.busy, onClick = repository::chooseQrImages) {
Text(stringResource(R.string.settings_qr_images))
}
Text(stringResource(R.string.settings_qr_images_detail))
SecretField(
stringResource(R.string.settings_qr_payload),
payload,
singleLine = false
) {
payload =
it
}
TextButton(enabled = !state.busy && payload.isNotEmpty(), onClick = {
repository.addQrPayload(payload)
payload =
""
}) { Text(stringResource(R.string.settings_qr_add)) }
Text(
stringResource(
R.string.settings_qr_progress,
state.transfer.received.toString(),
state.transfer.total.toString()
)
)
state.transfer.ready?.let {
Text(it.title)
Text(it.fingerprint)
SettingsToggle(stringResource(R.string.settings_make_default), makeDefault) {
makeDefault =
it
}
SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) {
overwrite =
it
}
TextButton(enabled = !state.busy, onClick = {
repository.importKey(passphrase, makeDefault, overwrite)
passphrase =
""
}) { Text(stringResource(R.string.settings_import_confirm)) }
}
}
},
confirmButton = {
TextButton(onClick = repository::closeTransfer) {
Text(stringResource(R.string.settings_done))
}
}
)
}
@Composable
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().toggleable(
value = checked,
role = Role.Checkbox,
onValueChange = change
)
) {
Checkbox(checked, onCheckedChange = null)
Text(label, modifier = Modifier.padding(vertical = 12.dp))
}
}
@@ -159,6 +159,7 @@ private fun ShellDestination(
(snapshot.state == ShellState.Empty && setup.configured)
)
when {
!snapshot.fixture && route == ShellRoute.Preferences -> SettingsScreen(viewModel, setupForm)
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(
(snapshot.state == ShellState.Empty && !setup.configured) ||
@@ -168,18 +169,6 @@ private fun ShellDestination(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (ready && route == ShellRoute.Preferences) {
TextButton(
onClick = {
setupForm.clearSecrets()
viewModel.openAppearance()
},
modifier = Modifier.testTag("shell-open-appearance")
) {
Text(stringResource(R.string.shell_appearance))
}
AuthenticationControls(viewModel.authentication)
}
OnboardingScreen(viewModel.onboarding, setupForm, scroll = false)
}
}
@@ -11,6 +11,7 @@ import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.SearchRepository
import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
@@ -28,6 +29,7 @@ class ShellViewModel @Inject constructor(
val search: SearchRepository,
val authentication: AuthenticationRepository,
val editor: EditorRepository,
val settings: SettingsRepository,
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
@@ -110,7 +112,10 @@ class ShellViewModel @Inject constructor(
ShellTab.Home -> home.leave()
ShellTab.Passwords -> passwords.leave()
ShellTab.Search -> search.cancel()
ShellTab.Preferences -> if (authentication.state.value.busy) authentication.cancel()
ShellTab.Preferences -> {
settings.cancel()
if (authentication.state.value.busy) authentication.cancel()
}
ShellTab.Totp -> Unit
}
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 rfc1437
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -148,4 +148,64 @@
<string name="editor_name">Name</string>
<string name="editor_create">Create</string>
<string name="editor_length_help">Leave blank for the default. Maximum: %1$d.</string>
<string name="settings_title">Preferences</string>
<string name="settings_default_key_select">Use selected private key as default</string>
<string name="settings_save">Save</string>
<string name="settings_search">Search settings</string>
<string name="settings_reload">Reload settings</string>
<string name="settings_store">Password store, server, token and GPG keys</string>
<string name="settings_git">Git commit author name and email</string>
<string name="settings_auth">Authentication, inactivity timeout, biometrics and appearance</string>
<string name="settings_cache">TOTP discovery cache</string>
<string name="settings_providers">Autofill and Credential Manager</string>
<string name="settings_recovery">Recovery and offline use</string>
<string name="settings_about">Version, licenses, privacy and support</string>
<string name="settings_no_token">No application token stored</string>
<string name="settings_token_hidden">Existing tokens and passphrases are never displayed. Replacement stores only the submitted value in protected device storage.</string>
<string name="settings_remote_token">HTTPS setup and token replacement</string>
<string name="settings_remove_token">Remove application token</string>
<string name="settings_key_transfer">Import or export GPG QR codes</string>
<string name="settings_edit_git">Edit Git author</string>
<string name="settings_biometry_available">Strong biometric authentication is available</string>
<string name="settings_biometry_unavailable">Strong biometric authentication is unavailable; use a GPG passphrase</string>
<string name="settings_timeout_value">Inactivity timeout: %1$s seconds</string>
<string name="settings_edit_timeout">Edit inactivity timeout</string>
<string name="settings_lock_now">Lock now</string>
<string name="settings_rebuild">Rebuild TOTP cache</string>
<string name="settings_autofill">Autofill password provider</string>
<string name="settings_credentials">Credential Manager password provider</string>
<string name="settings_recovery_key">If biometric enrollment changes or a protected passphrase is invalidated, unlock with the GPG passphrase and enable biometric unlock again. A missing device Keystore binding requires recovery from your original repository and GPG key; encrypted restored data is never silently accepted or deleted.</string>
<string name="settings_recovery_store">If the repository or GPG key is missing, keep existing data and restore the original encrypted repository and private key. Reload after recovery to see the storage-provided status.</string>
<string name="settings_recovery_cache">A damaged or outdated TOTP cache can be rebuilt after unlocking. The cache contains discovery metadata, never OTP seeds or codes.</string>
<string name="settings_offline">Offline use keeps local reading, editing and Git history available. HTTPS sync needs a network and an application token; failed network operations keep local data intact.</string>
<string name="settings_version">IronStorage %1$s</string>
<string name="settings_privacy">Passwords and private keys stay in protected app storage. No analytics or advertising are included. HTTPS synchronization connects only to the configured server. Secrets are hidden from screenshots, saved form state, logs and personalized keyboard learning.</string>
<string name="settings_licenses">MIT license</string>
<string name="settings_support">Support and issue tracker</string>
<string name="settings_dependency_licenses">Dependency license inventory</string>
<string name="settings_done">Done</string>
<string name="settings_provider_unsupported">Not supported on this Android version or device</string>
<string name="settings_provider_not_installed">The IronStorage provider service is not included in this build</string>
<string name="settings_provider_enabled">Enabled for IronStorage</string>
<string name="settings_provider_disabled">Available for IronStorage, currently disabled</string>
<string name="settings_provider_open">Open system provider settings</string>
<string name="settings_rebuild_detail">Enter the GPG passphrase to inspect encrypted entries and rebuild the discovery cache. No OTP seed is stored in the cache.</string>
<string name="settings_git_name">Git author name</string>
<string name="settings_git_email">Git author email</string>
<string name="settings_timeout_seconds">Timeout in seconds (1–86400)</string>
<string name="settings_remove_detail">Remove the protected HTTPS application token? Local data and commits remain available. Authenticated synchronization will require a replacement token.</string>
<string name="settings_private_warning">Private QR codes contain encrypted secret key material. Export only after checking the intended recipient. A private export requires its GPG passphrase.</string>
<string name="settings_private_export">Export private key</string>
<string name="settings_export_confirm">Confirm and export selected key</string>
<string name="settings_qr_frame">QR frame %1$s of %2$s</string>
<string name="settings_previous">Previous frame</string>
<string name="settings_next">Next frame</string>
<string name="settings_qr_images">Choose QR images</string>
<string name="settings_qr_images_detail">Select all frames of one transfer together. Opening another app clears the current transfer.</string>
<string name="settings_qr_payload">QR payload or armored GPG key</string>
<string name="settings_qr_add">Add transfer payload</string>
<string name="settings_qr_progress">Received %1$s of %2$s frames</string>
<string name="settings_make_default">Make imported private key the default</string>
<string name="settings_replace_key">Confirm replacement of an existing key</string>
<string name="settings_import_confirm">Confirm and import reconstructed key</string>
</resources>
@@ -0,0 +1,138 @@
package de.rfc1437.ironstorage.platform
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.credentials.CredentialManager
import android.os.Build
import android.provider.Settings
import android.view.autofill.AutofillManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import de.rfc1437.ironstorage.core.ProviderStatus
import de.rfc1437.ironstorage.core.SettingsPlatform
class AndroidSettingsPlatform(
private val context: Context,
private val keystore: AndroidKeystoreAdapter
) : SettingsPlatform {
private var picker: ActivityResultLauncher<String>? = null
private var host: java.lang.ref.WeakReference<FragmentActivity> = java.lang.ref.WeakReference(
null
)
fun attach(activity: FragmentActivity, selected: (List<String>) -> Unit) {
host = java.lang.ref.WeakReference(activity)
picker =
activity.activityResultRegistry.register(
"ironstorage.settings.qr-images",
activity,
ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) selected(uris.map { it.toString() })
}
}
fun detach(activity: FragmentActivity) {
if (host.get() === activity) {
picker = null
host.clear()
}
}
override fun chooseQrImages(): Boolean {
val picker = picker ?: return false
return try {
picker.launch("image/*")
true
} catch (
_: android.content.ActivityNotFoundException
) {
false
}
}
@Suppress("DEPRECATION")
private fun services(permission: String): List<ComponentName> =
context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SERVICES)
.services.orEmpty().filter { it.permission == permission }
.map { ComponentName(context.packageName, it.name) }
override fun autofill(): ProviderStatus {
val manager = context.getSystemService(AutofillManager::class.java)
return ProviderStatus(
manager?.isAutofillSupported == true,
services("android.permission.BIND_AUTOFILL_SERVICE").isNotEmpty(),
manager?.hasEnabledAutofillServices() == true
)
}
override fun credentials(): ProviderStatus {
val services = services("android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE")
val enabled = if (Build.VERSION.SDK_INT >= 34) {
val manager = context.getSystemService(CredentialManager::class.java)
services.any { manager?.isEnabledCredentialProviderService(it) == true }
} else {
false
}
return ProviderStatus(Build.VERSION.SDK_INT >= 34, services.isNotEmpty(), enabled)
}
override fun biometricsAvailable() = keystore.deviceState().strongBiometric
@Suppress("DEPRECATION")
override fun version(): String = context.packageManager.getPackageInfo(context.packageName, 0)
.versionName.orEmpty()
private fun open(intent: Intent): Boolean = try {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (
_: android.content.ActivityNotFoundException
) {
false
} catch (_: SecurityException) {
false
}
override fun openAutofill(): Boolean {
if (!autofill().supported) return false
return open(
Intent(
Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,
"package:${context.packageName}".toUri()
)
)
}
override fun openCredentials(): Boolean {
if (Build.VERSION.SDK_INT < 34) return false
val intent = if (Build.VERSION.SDK_INT >= 35) {
Intent(Settings.ACTION_CREDENTIAL_PROVIDER, "package:${context.packageName}".toUri())
} else {
Intent(
Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,
"package:${context.packageName}".toUri()
)
}
return open(intent)
}
override suspend fun readQrImage(uri: String): ByteArray {
val selected = uri.toUri()
if (selected.scheme != "content") throw java.io.IOException("Not a content URI")
return context.contentResolver.openInputStream(selected)?.use { input ->
val output = java.io.ByteArrayOutputStream()
val buffer = ByteArray(16384)
while (true) {
val count = input.read(buffer)
if (count < 0) break
if (output.size() + count >
32 * 1024 * 1024
) {
throw java.io.IOException("Image exceeds the supported size")
}
output.write(buffer, 0, count)
}
val bytes = output.toByteArray()
if (bytes.size > 32 * 1024 * 1024) {
bytes.fill(0)
throw java.io.IOException("Image exceeds the supported size")
}
bytes
} ?: throw java.io.IOException("Image unavailable")
}
}