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(