Harden Android accessibility, localization and startup (#105)
Implement independent hardening and regression coverage. Keep #105 open for final feature and environment coverage after #106–#110; document the completion boundary and resume order.
This commit is contained in:
1 parent
43f660ace3
commit
6669b77f4e
55 files changed
+24445
-296
No files matched your search
+58
-2
@@ -24,7 +24,7 @@ import org.junit.Test
|
||||
|
||||
/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */
|
||||
class NativeEntryViewerTest {
|
||||
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner() = runBlocking {
|
||||
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner(): Unit = runBlocking {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val name = "viewer-${UUID.randomUUID()}"
|
||||
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||
@@ -259,12 +259,45 @@ class NativeEntryViewerTest {
|
||||
password,
|
||||
keystore
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
val vault = File(roots[1], "ironstorage/vault")
|
||||
val ciphertext = File(vault, "editor/new.gpg").readBytes()
|
||||
repeat(50) { directory ->
|
||||
val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}")
|
||||
assertTrue(folder.mkdirs())
|
||||
repeat(20) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
|
||||
}
|
||||
val storage = session.storage()
|
||||
val navigateStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size)
|
||||
assertEquals(
|
||||
20,
|
||||
storage.passwordNavigation("load/slot-000", null, null, false).rows.size
|
||||
)
|
||||
val navigateMs = android.os.SystemClock.elapsedRealtime() - navigateStart
|
||||
assertTrue("1000-entry navigation must finish in 3 s", navigateMs < 3000)
|
||||
val searchStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertEquals(1000, storage.passwordNameSearch("load").size)
|
||||
assertEquals(20, storage.passwordNameSearch("load/slot-019").size)
|
||||
val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart
|
||||
assertTrue("1000-entry name search must finish in 5 s", searchMs < 5000)
|
||||
performance("tree_1000_navigation_ms", navigateMs)
|
||||
performance("tree_1000_search_ms", searchMs)
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performance(name: String, value: Long) {
|
||||
android.util.Log.i("IronStoragePerformance", "$name=$value")
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(
|
||||
0,
|
||||
android.os.Bundle().apply { putLong("ironstorage_$name", value) }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
|
||||
authentication: NativeAuthenticationRepository,
|
||||
passwords: NativePasswordRepository,
|
||||
@@ -344,7 +377,22 @@ class NativeEntryViewerTest {
|
||||
assertNull(request { editor.save() }.error)
|
||||
request { editor.begin("editor/new") }
|
||||
val passwordId = editor.state.value.fields.first().id
|
||||
request { editor.change(passwordId, null, "in-memory recovered secret") }
|
||||
val typingStart = android.os.SystemClock.elapsedRealtimeNanos()
|
||||
val updateTimes = LongArray(100)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
repeat(100) {
|
||||
val start = android.os.SystemClock.elapsedRealtimeNanos()
|
||||
editor.change(passwordId, null, "public typing $it")
|
||||
updateTimes[it] = android.os.SystemClock.elapsedRealtimeNanos() - start
|
||||
}
|
||||
editor.change(passwordId, null, "in-memory recovered secret")
|
||||
}
|
||||
val typingMs = (android.os.SystemClock.elapsedRealtimeNanos() - typingStart) / 1_000_000
|
||||
assertTrue("100 input updates must finish within 500 ms", typingMs < 500)
|
||||
val updateP95Us = updateTimes.sorted()[94] / 1000
|
||||
assertTrue("Input update p95 must fit within 32 ms", updateP95Us < 32000)
|
||||
performance("editor_100_updates_ms", typingMs)
|
||||
performance("editor_update_p95_us", updateP95Us)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||
}
|
||||
@@ -424,7 +472,11 @@ class NativeEntryViewerTest {
|
||||
assertEquals(45uL, settings.state.value.page!!.timeout)
|
||||
assertNotNull(request { settings.removeToken() }.error)
|
||||
assertNull(settings.state.value.page!!.account)
|
||||
val cacheStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertNull(request { settings.rebuildCache(passphrase) }.error)
|
||||
val cacheMs = android.os.SystemClock.elapsedRealtime() - cacheStart
|
||||
assertTrue("Fixture cache rebuild must finish in 15 s", cacheMs < 15000)
|
||||
performance("cache_rebuild_ms", cacheMs)
|
||||
assertTrue(settings.state.value.page!!.cacheDetail.contains("current TOTP entries"))
|
||||
val beforeEnrollment = keystore.keyCount
|
||||
authentication.authenticated { it.enrollBiometricUnlock(passphrase) }
|
||||
@@ -481,7 +533,11 @@ class NativeEntryViewerTest {
|
||||
image = output.toByteArray()
|
||||
request { settings.closeTransfer() }
|
||||
assertTrue(frame.modules.all { it.toInt() == 0 })
|
||||
val qrStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
|
||||
val qrMs = android.os.SystemClock.elapsedRealtime() - qrStart
|
||||
assertTrue("Fixture QR analysis must finish in 5 s", qrMs < 5000)
|
||||
performance("qr_analysis_ms", qrMs)
|
||||
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
|
||||
assertNotNull(request { settings.importKey("", false, false) }.error)
|
||||
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
|
||||
|
||||
@@ -23,7 +23,7 @@ data class EditorState(
|
||||
val open: Boolean = false,
|
||||
val locked: Boolean = false,
|
||||
val busy: Boolean = false,
|
||||
val title: String = "Entry editor",
|
||||
val title: String = "",
|
||||
val path: String = "",
|
||||
val fields: List<EditorField> = emptyList(),
|
||||
val dirty: Boolean = false,
|
||||
|
||||
+13
-11
@@ -28,6 +28,8 @@ class NativeAuthenticationRepository(
|
||||
private val scope: CoroutineScope,
|
||||
private val io: CoroutineDispatcher
|
||||
) : AuthenticationRepository {
|
||||
internal fun text(id: Int) = session.text(id)
|
||||
|
||||
override val presentation = ProtectedPresentation()
|
||||
private val mutable = MutableStateFlow(AuthenticationState())
|
||||
override val state: StateFlow<AuthenticationState> = mutable
|
||||
@@ -72,15 +74,15 @@ class NativeAuthenticationRepository(
|
||||
mutable.value = mutable.value.copy(busy = true, failure = null)
|
||||
operation.withLock {
|
||||
revocation?.join()
|
||||
if (ticket != sequence.get()) throw cancelled()
|
||||
if (ticket != sequence.get()) throw cancelled(session)
|
||||
try {
|
||||
val result = withContext(io) {
|
||||
val authentication = owner()
|
||||
if (ticket != sequence.get()) throw cancelled()
|
||||
if (ticket != sequence.get()) throw cancelled(session)
|
||||
val result = block(authentication)
|
||||
if (ticket != sequence.get()) {
|
||||
authentication.manualLock()
|
||||
throw cancelled()
|
||||
throw cancelled(session)
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -121,7 +123,7 @@ class NativeAuthenticationRepository(
|
||||
version: ULong,
|
||||
fields: List<de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput>
|
||||
) = withContext(io) {
|
||||
(current ?: throw cancelled()).checkpointEntryEditorInputs(editor, version, fields)
|
||||
(current ?: throw cancelled(session)).checkpointEntryEditorInputs(editor, version, fields)
|
||||
}
|
||||
|
||||
internal suspend fun discardEditor(editor: ULong) = withContext(io) {
|
||||
@@ -168,8 +170,8 @@ class NativeAuthenticationRepository(
|
||||
appearance = mutable.value.appearance,
|
||||
failure = AuthenticationFailure(
|
||||
AuthenticationFailureKind.Configuration,
|
||||
"Authentication is unavailable",
|
||||
"Unlock the device or recover private storage and reload."
|
||||
session.text(R.string.core_auth_unavailable),
|
||||
session.text(R.string.core_auth_recover)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -189,8 +191,8 @@ class NativeAuthenticationRepository(
|
||||
failure = if (cancel) {
|
||||
AuthenticationFailure(
|
||||
AuthenticationFailureKind.Cancelled,
|
||||
"Authentication cancelled",
|
||||
"No protected content was unlocked."
|
||||
session.text(R.string.core_auth_cancelled),
|
||||
session.text(R.string.core_auth_no_content)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -243,10 +245,10 @@ class NativeAuthenticationRepository(
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun cancelled() = MobileAuthenticationFfiException.Failed(
|
||||
private fun cancelled(session: NativePrivateSession) = MobileAuthenticationFfiException.Failed(
|
||||
MobileAuthenticationErrorKind.CANCELLED,
|
||||
"Authentication cancelled",
|
||||
"No protected content was unlocked."
|
||||
session.text(R.string.core_auth_cancelled),
|
||||
session.text(R.string.core_auth_no_content)
|
||||
)
|
||||
private fun MobileAuthenticationState.toFacade() = AuthenticationState(
|
||||
unlocked,
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -140,6 +141,8 @@ class NativeEditorRepository(
|
||||
pending?.cancel()
|
||||
pending = scope.launch {
|
||||
try {
|
||||
// Coalesce typing; save, reorder and recovery join the latest encrypted checkpoint.
|
||||
delay(150)
|
||||
authentication.checkpointEditor(editor, inputVersion, fields)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
@@ -155,7 +158,7 @@ class NativeEditorRepository(
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
mutable.value.copy(error = authentication.text(R.string.core_update_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +244,7 @@ class NativeEditorRepository(
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
busy = false,
|
||||
error = "Update the application and retry."
|
||||
error = authentication.text(R.string.core_update_retry)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -277,7 +280,7 @@ class NativeEditorRepository(
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Unlock the device and retry."
|
||||
error = authentication.text(R.string.core_unlock_retry)
|
||||
)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
@@ -285,7 +288,9 @@ class NativeEditorRepository(
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
mutable.value.copy(
|
||||
error = authentication.text(R.string.core_update_retry)
|
||||
)
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
if (ticket ==
|
||||
@@ -293,7 +298,7 @@ class NativeEditorRepository(
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Storage unavailable. Keep the draft and retry."
|
||||
error = authentication.text(R.string.core_draft_storage_unavailable)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -124,14 +124,14 @@ class NativeHomeRepository(
|
||||
} catch (_: MobilePrivateStorageFfiException) {
|
||||
failure(
|
||||
ticket,
|
||||
"Unlock the device or recover private storage, then reload Home."
|
||||
session.text(R.string.core_home_recover)
|
||||
)
|
||||
} catch (_: NativeBridgeException) {
|
||||
failure(ticket, "Update the app and reload Home.")
|
||||
failure(ticket, session.text(R.string.core_home_update))
|
||||
} catch (_: java.io.IOException) {
|
||||
failure(
|
||||
ticket,
|
||||
"Private storage is unavailable. Unlock the device and reload Home."
|
||||
session.text(R.string.core_home_storage)
|
||||
)
|
||||
} finally {
|
||||
active = null
|
||||
@@ -146,7 +146,11 @@ class NativeHomeRepository(
|
||||
private fun failure(ticket: Long, detail: String) {
|
||||
if (sequence.get() == ticket) {
|
||||
mutable.value = mutable.value.copy(
|
||||
failure = HomeFailure(HomeFailureKind.Repository, "Home is unavailable", detail)
|
||||
failure = HomeFailure(
|
||||
HomeFailureKind.Repository,
|
||||
session.text(R.string.core_home_unavailable),
|
||||
detail
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-21
@@ -53,7 +53,7 @@ class NativeOnboardingRepository(
|
||||
operation = it
|
||||
if (revoked.get()) {
|
||||
it.cancel()
|
||||
throw interrupted()
|
||||
throw interrupted(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,11 @@ class NativeOnboardingRepository(
|
||||
it.cancel()
|
||||
it.close()
|
||||
remote = null
|
||||
throw interrupted()
|
||||
throw interrupted(session)
|
||||
}
|
||||
}
|
||||
val discovery = request.discover()
|
||||
if (revoked.get()) throw interrupted()
|
||||
if (revoked.get()) throw interrupted(session)
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
@@ -88,8 +88,8 @@ class NativeOnboardingRepository(
|
||||
selectedBranch = discovery.branches.getOrNull(
|
||||
discovery.selectedBranch.toInt()
|
||||
).orEmpty(),
|
||||
title = "Select a branch",
|
||||
detail = "Choose the branch and private GPG key for this store."
|
||||
title = session.text(R.string.core_branch_title),
|
||||
detail = session.text(R.string.core_branch_detail)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class NativeOnboardingRepository(
|
||||
useExisting: Boolean
|
||||
) = runSetup {
|
||||
completed(
|
||||
(remote ?: throw interrupted()).cloneStore(
|
||||
(remote ?: throw interrupted(session)).cloneStore(
|
||||
branch,
|
||||
fingerprint,
|
||||
passphrase.ifEmpty {
|
||||
@@ -117,12 +117,12 @@ class NativeOnboardingRepository(
|
||||
}
|
||||
override fun replaceToken(account: String, token: String) = runSetup {
|
||||
newOperation().replaceToken(account, token)
|
||||
if (revoked.get()) throw interrupted()
|
||||
if (revoked.get()) throw interrupted(session)
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Token updated",
|
||||
detail = "The previous token was replaced in protected device storage."
|
||||
title = session.text(R.string.core_token_updated),
|
||||
detail = session.text(R.string.core_token_updated_detail)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ class NativeOnboardingRepository(
|
||||
}
|
||||
}
|
||||
private fun completed(outcome: MobileOnboardingOutcome) {
|
||||
if (revoked.get()) throw interrupted()
|
||||
if (revoked.get()) throw interrupted(session)
|
||||
updateStatus(requireNotNull(operation).status())
|
||||
mutableSetup.update {
|
||||
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
|
||||
@@ -155,7 +155,7 @@ class NativeOnboardingRepository(
|
||||
shell.refresh()
|
||||
}
|
||||
private fun updateStatus(status: MobileAndroidSetupStatus) {
|
||||
if (revoked.get()) throw interrupted()
|
||||
if (revoked.get()) throw interrupted(session)
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
@@ -215,8 +215,8 @@ class NativeOnboardingRepository(
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Private storage unavailable",
|
||||
detail = "Unlock the device and retry. Existing data was retained.",
|
||||
title = session.text(R.string.core_private_unavailable),
|
||||
detail = session.text(R.string.core_private_retained),
|
||||
failure = true
|
||||
)
|
||||
}
|
||||
@@ -224,8 +224,8 @@ class NativeOnboardingRepository(
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Application update required",
|
||||
detail = "The native storage service could not be loaded.",
|
||||
title = session.text(R.string.core_update_required),
|
||||
detail = session.text(R.string.core_native_unavailable),
|
||||
failure = true
|
||||
)
|
||||
}
|
||||
@@ -233,14 +233,14 @@ class NativeOnboardingRepository(
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Private storage unavailable",
|
||||
detail = "The app-private directories could not be opened.",
|
||||
title = session.text(R.string.core_private_unavailable),
|
||||
detail = session.text(R.string.core_directories_unavailable),
|
||||
failure = true
|
||||
)
|
||||
}
|
||||
} catch (error: IllegalStateException) {
|
||||
if (!revoked.get()) throw error
|
||||
val failure = interrupted()
|
||||
val failure = interrupted(session)
|
||||
mutableSetup.update {
|
||||
it.copy(title = failure.title, detail = failure.detail, failure = true)
|
||||
}
|
||||
@@ -261,8 +261,8 @@ class NativeOnboardingRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun interrupted() = MobileOnboardingFfiException.Failed(
|
||||
private fun interrupted(session: NativePrivateSession) = MobileOnboardingFfiException.Failed(
|
||||
MobileOnboardingErrorKind.INTERRUPTED,
|
||||
"Setup interrupted",
|
||||
"Return to setup and enter its secrets again."
|
||||
session.text(R.string.core_setup_interrupted),
|
||||
session.text(R.string.core_setup_return)
|
||||
)
|
||||
+13
-7
@@ -283,7 +283,10 @@ class NativePasswordRepository(
|
||||
try {
|
||||
clearMobileEntryCopy(copy, callback)
|
||||
} catch (_: MobilePlatformFfiException) {
|
||||
mutable.value = mutable.value.copy(error = "Clipboard cleanup is unavailable.")
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = session.text(R.string.core_clipboard_cleanup_unavailable)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,7 +301,10 @@ class NativePasswordRepository(
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (ticket == sequence.get()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Copy is unavailable.", notice = null)
|
||||
mutable.value.copy(
|
||||
error = session.text(R.string.core_copy_unavailable),
|
||||
notice = null
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -308,9 +314,9 @@ class NativePasswordRepository(
|
||||
}
|
||||
}
|
||||
if (written.await()) {
|
||||
current.copy(notice = "Copied. The clipboard will be cleared automatically.")
|
||||
current.copy(notice = session.text(R.string.core_copied))
|
||||
} else {
|
||||
current.copy(error = "Copy is unavailable.", notice = null)
|
||||
current.copy(error = session.text(R.string.core_copy_unavailable), notice = null)
|
||||
}
|
||||
}
|
||||
override fun prepareMutation(action: PasswordMutation) = content { current ->
|
||||
@@ -386,13 +392,13 @@ class NativePasswordRepository(
|
||||
mutable.value.copy(error = error.detail)
|
||||
} catch (_: MobilePrivateStorageFfiException) {
|
||||
mutable.value.copy(
|
||||
error = "Unlock the device or recover the private store and refresh."
|
||||
error = session.text(R.string.core_password_recovery)
|
||||
)
|
||||
} catch (_: NativeBridgeException) {
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
mutable.value.copy(error = session.text(R.string.core_update_retry))
|
||||
} catch (_: java.io.IOException) {
|
||||
mutable.value.copy(
|
||||
error = "Private storage is unavailable. Unlock the device and refresh.",
|
||||
error = session.text(R.string.core_password_storage),
|
||||
selected = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
|
||||
|
||||
/** Both facades share one Rust owner and retain its Keystore callbacks for its lifetime. */
|
||||
class NativePrivateSession(
|
||||
context: Context,
|
||||
private val context: Context,
|
||||
private val directories: () -> PrivateDirectories,
|
||||
private val keystore: AndroidKeystore
|
||||
) {
|
||||
internal fun text(id: Int): String = context.getString(id)
|
||||
|
||||
val runtime = NativeRuntime(context)
|
||||
|
||||
fun cancelAuthentication() = keystore.cancelPending()
|
||||
|
||||
+20
-14
@@ -64,22 +64,31 @@ class NativeSettingsRepository(
|
||||
releaseTransfer()
|
||||
authentication.authenticated { it.setDefaultKey(fingerprint) }
|
||||
authentication.lock()
|
||||
mutable.value = mutable.value.copy(page = page(), notice = "Default GPG key updated.")
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
page = page(),
|
||||
notice = session.text(R.string.core_default_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.")
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
page = page(),
|
||||
notice = session.text(R.string.core_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.")
|
||||
mutable.value =
|
||||
mutable.value.copy(page = page(), notice = session.text(R.string.core_timeout_updated))
|
||||
}
|
||||
override fun removeToken() = run {
|
||||
authentication.authenticated { it.removeApplicationToken() }
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
page = page(),
|
||||
notice = "Application token removed from protected storage."
|
||||
notice = session.text(R.string.core_token_removed)
|
||||
)
|
||||
}
|
||||
override fun rebuildCache(passphrase: String) = run {
|
||||
@@ -94,7 +103,7 @@ class NativeSettingsRepository(
|
||||
mutable.value.copy(
|
||||
page = page(),
|
||||
notice =
|
||||
result.cacheNotice ?: "TOTP cache rebuilt."
|
||||
result.cacheNotice ?: session.text(R.string.core_cache_rebuilt)
|
||||
)
|
||||
} finally {
|
||||
discovery = null
|
||||
@@ -105,7 +114,7 @@ class NativeSettingsRepository(
|
||||
if (!platform.openAutofill()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Autofill provider settings are unavailable on this device."
|
||||
error = session.text(R.string.core_autofill_settings_unavailable)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +122,7 @@ class NativeSettingsRepository(
|
||||
if (!platform.openCredentials()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(
|
||||
error = "Credential provider settings are unavailable on this device."
|
||||
error = session.text(R.string.core_credential_settings_unavailable)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -184,7 +193,7 @@ class NativeSettingsRepository(
|
||||
override fun chooseQrImages() {
|
||||
if (!platform.chooseQrImages()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "The image picker is unavailable on this device.")
|
||||
mutable.value.copy(error = session.text(R.string.core_picker_unavailable))
|
||||
}
|
||||
}
|
||||
override fun addQrImages(uris: List<String>) {
|
||||
@@ -294,10 +303,7 @@ class NativeSettingsRepository(
|
||||
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."
|
||||
session.text(R.string.core_private_recovery)
|
||||
)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
@@ -305,14 +311,14 @@ class NativeSettingsRepository(
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Update the application and retry.")
|
||||
mutable.value.copy(error = session.text(R.string.core_update_retry))
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
if (ticket ==
|
||||
epoch
|
||||
) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "The selected QR image could not be read.")
|
||||
mutable.value.copy(error = session.text(R.string.core_image_unreadable))
|
||||
}
|
||||
} finally {
|
||||
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
|
||||
|
||||
@@ -12,8 +12,8 @@ data class SetupStatus(
|
||||
val branches: List<String> = emptyList(),
|
||||
val selectedBranch: String = "",
|
||||
val busy: Boolean = false,
|
||||
val title: String = "Set up your password store",
|
||||
val detail: String = "Create a local store or clone an existing HTTPS repository.",
|
||||
val title: String = "",
|
||||
val detail: String = "",
|
||||
val failure: Boolean = false
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ data class PasswordMutationPlan(
|
||||
)
|
||||
data class PasswordState(
|
||||
val path: String = "",
|
||||
val title: String = "Passwords",
|
||||
val title: String = "",
|
||||
val parent: String? = null,
|
||||
val revision: String = "",
|
||||
val rows: List<PasswordRow> = emptyList(),
|
||||
|
||||
@@ -33,6 +33,7 @@ interface SearchRepository {
|
||||
class NativeSearchRepository internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
presentation: ProtectedPresentation,
|
||||
private val failureText: () -> String = { "" },
|
||||
private val load: suspend (String) -> List<PasswordRow>
|
||||
) : SearchRepository {
|
||||
constructor(
|
||||
@@ -40,7 +41,7 @@ class NativeSearchRepository internal constructor(
|
||||
scope: CoroutineScope,
|
||||
io: CoroutineDispatcher,
|
||||
presentation: ProtectedPresentation
|
||||
) : this(scope, presentation, { query ->
|
||||
) : this(scope, presentation, { session.text(R.string.core_search_unavailable) }, { query ->
|
||||
withContext(io) {
|
||||
session.storage().passwordNameSearch(query).map { it.toFacade() }
|
||||
}
|
||||
@@ -95,7 +96,7 @@ class NativeSearchRepository internal constructor(
|
||||
if (sequence == ticket) {
|
||||
mutable.value = SearchState(
|
||||
query,
|
||||
error = "Search is unavailable. Unlock the device or recover the store, then retry."
|
||||
error = failureText()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="core_update_retry">Update the application and retry.</string>
|
||||
<string name="core_unlock_retry">Unlock the device and retry.</string>
|
||||
<string name="core_draft_storage_unavailable">Storage unavailable. Keep the draft and retry.</string>
|
||||
<string name="core_home_recover">Unlock the device or recover private storage, then reload Home.</string>
|
||||
<string name="core_home_update">Update the app and reload Home.</string>
|
||||
<string name="core_home_storage">Private storage is unavailable. Unlock the device and reload Home.</string>
|
||||
<string name="core_home_unavailable">Home is unavailable</string>
|
||||
<string name="core_search_unavailable">Search is unavailable. Unlock the device or recover the store, then retry.</string>
|
||||
<string name="core_auth_unavailable">Authentication is unavailable</string>
|
||||
<string name="core_auth_recover">Unlock the device or recover private storage and reload.</string>
|
||||
<string name="core_auth_cancelled">Authentication cancelled</string>
|
||||
<string name="core_auth_no_content">No protected content was unlocked.</string>
|
||||
<string name="core_branch_title">Select a branch</string>
|
||||
<string name="core_branch_detail">Choose the branch and private GPG key for this store.</string>
|
||||
<string name="core_token_updated">Token updated</string>
|
||||
<string name="core_token_updated_detail">The previous token was replaced in protected device storage.</string>
|
||||
<string name="core_private_unavailable">Private storage unavailable</string>
|
||||
<string name="core_private_retained">Unlock the device and retry. Existing data was retained.</string>
|
||||
<string name="core_update_required">Application update required</string>
|
||||
<string name="core_native_unavailable">The native storage service could not be loaded.</string>
|
||||
<string name="core_directories_unavailable">The app-private directories could not be opened.</string>
|
||||
<string name="core_setup_interrupted">Setup interrupted</string>
|
||||
<string name="core_setup_return">Return to setup and enter its secrets again.</string>
|
||||
<string name="core_default_key_updated">Default GPG key updated.</string>
|
||||
<string name="core_git_author_updated">Git author updated.</string>
|
||||
<string name="core_timeout_updated">Inactivity timeout updated.</string>
|
||||
<string name="core_token_removed">Application token removed from protected storage.</string>
|
||||
<string name="core_cache_rebuilt">TOTP cache rebuilt.</string>
|
||||
<string name="core_autofill_settings_unavailable">Autofill provider settings are unavailable on this device.</string>
|
||||
<string name="core_credential_settings_unavailable">Credential provider settings are unavailable on this device.</string>
|
||||
<string name="core_picker_unavailable">The image picker is unavailable on this device.</string>
|
||||
<string name="core_private_recovery">Private storage recovery required. Unlock the device and reload; missing or invalidated Keystore data requires reimporting the GPG key.</string>
|
||||
<string name="core_image_unreadable">The selected QR image could not be read.</string>
|
||||
<string name="core_clipboard_cleanup_unavailable">Clipboard cleanup is unavailable.</string>
|
||||
<string name="core_copy_unavailable">Copy is unavailable.</string>
|
||||
<string name="core_copied">Copied. The clipboard will be cleared automatically.</string>
|
||||
<string name="core_password_recovery">Unlock the device or recover the private store and refresh.</string>
|
||||
<string name="core_password_storage">Private storage is unavailable. Unlock the device and refresh.</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user