Implement confirmed Android tree mutation workflows (#108)
This commit is contained in:
1 parent
6a864a8d7f
commit
316a875d63
22 files changed
+2170
-220
No files matched your search
@@ -281,66 +281,29 @@ fun EntryDialogs(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
|
||||
)
|
||||
}
|
||||
state.mutation?.let { plan ->
|
||||
var destination by remember(plan) { mutableStateOf<String?>(null) }
|
||||
var overwrite by remember(plan, destination) { mutableStateOf(false) }
|
||||
val selected = plan.destinations.find { it.path == destination }
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
onDismissRequest = repository::dismissMutation,
|
||||
title = { Text(stringResource(plan.action.label())) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(plan.source)
|
||||
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
|
||||
if (plan.action == PasswordMutation.Delete) {
|
||||
Text(stringResource(R.string.entry_delete_confirm))
|
||||
} else {
|
||||
plan.destinations.forEach { candidate ->
|
||||
TextButton(onClick = { destination = candidate.path }) {
|
||||
Text(candidate.title + "\n" + candidate.detail)
|
||||
}
|
||||
}
|
||||
selected?.let { Text(stringResource(R.string.entry_destination, it.path)) }
|
||||
if (selected?.overwrite == true) {
|
||||
TextButton(onClick = { overwrite = !overwrite }) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (overwrite) {
|
||||
R.string.entry_overwrite_enabled
|
||||
} else {
|
||||
R.string.entry_overwrite
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !state.loading && (
|
||||
plan.action == PasswordMutation.Delete ||
|
||||
selected != null && (!selected.overwrite || overwrite)
|
||||
),
|
||||
onClick = { repository.mutate(destination, overwrite) }
|
||||
) {
|
||||
Text(stringResource(R.string.entry_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = repository::dismissMutation) {
|
||||
Text(stringResource(R.string.auth_cancel))
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
|
||||
)
|
||||
}
|
||||
internal fun PasswordMutation.label(
|
||||
kind: de.rfc1437.ironstorage.core.PasswordKind = de.rfc1437.ironstorage.core.PasswordKind.Entry
|
||||
) = when (this) {
|
||||
PasswordMutation.Move -> if (kind ==
|
||||
de.rfc1437.ironstorage.core.PasswordKind.Folder
|
||||
) {
|
||||
R.string.mutation_move_folder
|
||||
} else {
|
||||
R.string.entry_move
|
||||
}
|
||||
PasswordMutation.Copy -> if (kind ==
|
||||
de.rfc1437.ironstorage.core.PasswordKind.Folder
|
||||
) {
|
||||
R.string.mutation_copy_folder
|
||||
} else {
|
||||
R.string.entry_copy_entry
|
||||
}
|
||||
PasswordMutation.Delete -> if (kind ==
|
||||
de.rfc1437.ironstorage.core.PasswordKind.Folder
|
||||
) {
|
||||
R.string.mutation_delete_folder
|
||||
} else {
|
||||
R.string.entry_delete
|
||||
}
|
||||
}
|
||||
private fun PasswordMutation.label() = when (this) {
|
||||
PasswordMutation.Move -> R.string.entry_move
|
||||
PasswordMutation.Copy -> R.string.entry_copy_entry
|
||||
PasswordMutation.Delete -> R.string.entry_delete
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordMutation
|
||||
import de.rfc1437.ironstorage.core.PasswordMutationPlan
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordState
|
||||
|
||||
@Composable
|
||||
internal fun MutationDialog(
|
||||
plan: PasswordMutationPlan,
|
||||
state: PasswordState,
|
||||
repository: PasswordRepository,
|
||||
authentication: AuthenticationRepository
|
||||
) {
|
||||
val lease by authentication.state.collectAsStateWithLifecycle()
|
||||
var destination by remember(plan) { mutableStateOf<String?>(null) }
|
||||
var overwrite by remember(plan, destination) { mutableStateOf(false) }
|
||||
var discard by remember(plan) { mutableStateOf(false) }
|
||||
var manual by remember(plan) { mutableStateOf(false) }
|
||||
var requested by remember(plan) { mutableStateOf(false) }
|
||||
val selected = plan.destinations.find { it.path == destination }
|
||||
LaunchedEffect(lease.failure, lease.busy, state.loading) {
|
||||
if (requested && !lease.busy && !state.loading) {
|
||||
requested = false
|
||||
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
|
||||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
|
||||
) {
|
||||
manual = true
|
||||
}
|
||||
}
|
||||
}
|
||||
val cancel = {
|
||||
repository.dismissMutation()
|
||||
if (lease.busy) authentication.cancel()
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = cancel,
|
||||
title = { Text(stringResource(plan.action.label(plan.kind))) },
|
||||
text = {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxWidth().heightIn(max = 400.dp).testTag("mutation-destinations")
|
||||
) {
|
||||
item {
|
||||
Text(plan.sourceTitle, style = MaterialTheme.typography.titleLarge)
|
||||
Text(plan.source)
|
||||
if (plan.kind == PasswordKind.Folder) {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.mutation_entries,
|
||||
plan.entries.coerceAtMost(Int.MAX_VALUE.toUInt()).toInt(),
|
||||
plan.entries.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
lease.failure?.let {
|
||||
Text(it.title + "\n" + it.detail, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (!lease.unlocked) {
|
||||
TextButton(enabled = !lease.busy && !state.loading, onClick = {
|
||||
requested = true
|
||||
repository.unlockMutation("")
|
||||
}) { Text(stringResource(R.string.password_unlock)) }
|
||||
TextButton(enabled = !lease.busy, onClick = { manual = true }) {
|
||||
Text(stringResource(R.string.auth_manual))
|
||||
}
|
||||
}
|
||||
if (plan.hasOpenEditor) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (plan.hasDirtyEditor) {
|
||||
R.string.mutation_dirty_editor
|
||||
} else {
|
||||
R.string.mutation_open_editor
|
||||
}
|
||||
)
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) {
|
||||
discard = it
|
||||
}
|
||||
) {
|
||||
Checkbox(discard, onCheckedChange = null)
|
||||
Text(stringResource(R.string.mutation_discard_editor))
|
||||
}
|
||||
}
|
||||
if (plan.action == PasswordMutation.Delete) {
|
||||
Text(stringResource(R.string.mutation_delete_target, plan.source))
|
||||
}
|
||||
}
|
||||
if (plan.action != PasswordMutation.Delete) {
|
||||
items(plan.destinations, key = { it.path }) { candidate ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(
|
||||
start = (
|
||||
candidate.depth.coerceAtMost(12u).toInt() *
|
||||
12
|
||||
).dp
|
||||
)
|
||||
.selectable(
|
||||
selected = destination == candidate.path,
|
||||
enabled =
|
||||
candidate.unavailable == null,
|
||||
role = Role.RadioButton,
|
||||
onClick = { destination = candidate.path }
|
||||
)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = destination == candidate.path,
|
||||
onClick = null,
|
||||
enabled =
|
||||
candidate.unavailable == null
|
||||
)
|
||||
Column {
|
||||
Text(candidate.title + "\n" + candidate.detail)
|
||||
candidate.unavailable?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected?.let { target ->
|
||||
item {
|
||||
Text(stringResource(R.string.entry_destination, target.path))
|
||||
if (target.overwrite) {
|
||||
Text(stringResource(R.string.mutation_collision))
|
||||
target.collisionTargets.forEach { Text(it) }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
overwrite,
|
||||
role = Role.Checkbox
|
||||
) {
|
||||
overwrite = it
|
||||
}
|
||||
) {
|
||||
Checkbox(overwrite, onCheckedChange = null)
|
||||
Text(stringResource(R.string.entry_overwrite))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = lease.unlocked && !state.loading && !lease.busy &&
|
||||
(!plan.hasOpenEditor || discard) &&
|
||||
(
|
||||
plan.action == PasswordMutation.Delete ||
|
||||
selected != null && selected.unavailable == null &&
|
||||
(!selected.overwrite || overwrite)
|
||||
),
|
||||
onClick = { repository.mutate(destination, overwrite, discard) }
|
||||
) {
|
||||
Text(stringResource(R.string.entry_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = cancel) { Text(stringResource(R.string.auth_cancel)) }
|
||||
}
|
||||
)
|
||||
if (manual) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_manual),
|
||||
stringResource(R.string.auth_manual_detail),
|
||||
presentation = authentication.presentation,
|
||||
onCancel = {
|
||||
manual = false
|
||||
authentication.cancel()
|
||||
},
|
||||
onSubmit = {
|
||||
manual = false
|
||||
requested = true
|
||||
repository.unlockMutation(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+130
-46
@@ -1,8 +1,10 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -18,6 +20,8 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
@@ -25,13 +29,17 @@ import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -50,8 +58,10 @@ import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.EditorRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordMutation
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
@@ -150,6 +160,7 @@ fun PasswordScreen(
|
||||
)
|
||||
Text(state.path.ifEmpty { stringResource(R.string.password_root) })
|
||||
if (state.loading) CircularProgressIndicator()
|
||||
state.notice?.let { Text(it) }
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
@@ -182,57 +193,121 @@ fun PasswordScreen(
|
||||
R.string.password_entry
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(row.title) },
|
||||
supportingContent = { Text(row.detail) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painterResource(
|
||||
when (row.icon) {
|
||||
ShellIcon.Folder -> R.drawable.ic_folder
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
else -> error("Invalid Rust navigation icon")
|
||||
}
|
||||
),
|
||||
null
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
rowActions
|
||||
},
|
||||
enabled = !state.loading,
|
||||
onClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
val mutationLabels = PasswordMutation.entries.associateWith { action ->
|
||||
stringResource(
|
||||
R.string.mutation_action_for,
|
||||
stringResource(action.label(row.kind)),
|
||||
row.title
|
||||
)
|
||||
}
|
||||
key(row.id, row.revision) {
|
||||
val swipe = rememberSwipeToDismissBoxState()
|
||||
val swipeScope = rememberCoroutineScope()
|
||||
SwipeToDismissBox(
|
||||
state = swipe,
|
||||
gesturesEnabled = !state.loading,
|
||||
onDismiss = { direction ->
|
||||
val action = if (direction ==
|
||||
SwipeToDismissBoxValue.EndToStart
|
||||
) {
|
||||
PasswordMutation.Delete
|
||||
} else {
|
||||
PasswordMutation.Move
|
||||
}
|
||||
) { Text(actionLabel) }
|
||||
},
|
||||
modifier = Modifier.combinedClickable(
|
||||
enabled = !state.loading,
|
||||
onClickLabel = stringResource(R.string.password_open),
|
||||
onLongClickLabel = rowActions,
|
||||
onClick = {
|
||||
actions = false
|
||||
repository.activate(row.id, state.revision)
|
||||
repository.prepareRowMutation(row.id, row.revision, action)
|
||||
swipeScope.launch { swipe.reset() }
|
||||
},
|
||||
onLongClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
backgroundContent = {
|
||||
val action = if (swipe.dismissDirection ==
|
||||
SwipeToDismissBoxValue.EndToStart
|
||||
) {
|
||||
PasswordMutation.Delete
|
||||
} else {
|
||||
PasswordMutation.Move
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
).padding(16.dp),
|
||||
contentAlignment = if (action ==
|
||||
PasswordMutation.Delete
|
||||
) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
}
|
||||
) {
|
||||
Text(mutationLabels.getValue(action))
|
||||
}
|
||||
}
|
||||
).semantics {
|
||||
stateDescription = kindLabel
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(rowActions) {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
true
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(row.title) },
|
||||
supportingContent = { Text(row.detail) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painterResource(
|
||||
when (row.icon) {
|
||||
ShellIcon.Folder -> R.drawable.ic_folder
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
else -> error(
|
||||
"Invalid Rust navigation icon"
|
||||
)
|
||||
}
|
||||
),
|
||||
null
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
rowActions
|
||||
},
|
||||
enabled = !state.loading,
|
||||
onClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
}
|
||||
) { Text(actionLabel) }
|
||||
},
|
||||
modifier = Modifier.combinedClickable(
|
||||
enabled = !state.loading,
|
||||
onClickLabel = stringResource(R.string.password_open),
|
||||
onLongClickLabel = rowActions,
|
||||
onClick = {
|
||||
actions = false
|
||||
repository.activate(row.id, state.revision)
|
||||
},
|
||||
onLongClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
}
|
||||
).semantics {
|
||||
stateDescription = kindLabel
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(rowActions) {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
true
|
||||
}
|
||||
) + PasswordMutation.entries.map { action ->
|
||||
CustomAccessibilityAction(
|
||||
mutationLabels.getValue(action)
|
||||
) {
|
||||
actions = false
|
||||
repository.prepareRowMutation(
|
||||
row.id,
|
||||
row.revision,
|
||||
action
|
||||
)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
@@ -331,6 +406,7 @@ fun PasswordScreen(
|
||||
) {
|
||||
EntryDialogs(state, repository, authentication)
|
||||
}
|
||||
state.mutation?.let { MutationDialog(it, state, repository, authentication) }
|
||||
if (manual && entry != null) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_manual),
|
||||
@@ -363,7 +439,15 @@ fun PasswordScreen(
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Text(selected.detail + "\n" + selected.path)
|
||||
Column {
|
||||
Text(selected.detail + "\n" + selected.path)
|
||||
PasswordMutation.entries.forEach { action ->
|
||||
TextButton(onClick = {
|
||||
actions = false
|
||||
repository.prepareRowMutation(selected.id, selected.revision, action)
|
||||
}) { Text(stringResource(action.label(selected.kind))) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
|
||||
@@ -253,4 +253,17 @@
|
||||
<string name="totp_unavailable">This entry no longer has a TOTP code.</string>
|
||||
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
|
||||
<string name="totp_back">Back to TOTP entries</string>
|
||||
<string name="mutation_action_for">%1$s %2$s</string>
|
||||
<string name="mutation_move_folder">Move folder</string>
|
||||
<string name="mutation_copy_folder">Copy folder</string>
|
||||
<string name="mutation_delete_folder">Delete folder</string>
|
||||
<string name="mutation_delete_target">Delete %1$s and its contents? This requires confirmation.</string>
|
||||
<string name="mutation_open_editor">This target has an open editor.</string>
|
||||
<string name="mutation_dirty_editor">This target has unsaved edits.</string>
|
||||
<string name="mutation_discard_editor">Discard its open editors and unsaved edits</string>
|
||||
<string name="mutation_collision">These existing entries will be replaced:</string>
|
||||
<plurals name="mutation_entries">
|
||||
<item quantity="one">%1$s password entry</item>
|
||||
<item quantity="other">%1$s password entries</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
Reference in new issue
Block a user