Harden Android accessibility, localization and startup (#105)
Android ARM verification / rust (push) Failing after 46m41s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

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:
hugo committed 2026-10-11 15:56:33 +02:00
1 parent 43f660ace3
commit 6669b77f4e
55 files changed
+24445 -296

No files matched your search

@@ -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,
@@ -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
)
)
}
}
@@ -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)
)
@@ -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()
@@ -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>