Implement complete Android settings and recovery controls (#104)
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user