style(cordn): group the five form screens into SettingsSections

The hub was converted earlier; these five kept the shared geometry but
were still flat columns divided by horizontal rules. A rule says
"something else follows" without saying what, which on two of these
screens is the whole problem:

- Migrate is two devices' jobs and only one of them is yours today.
  Doing the wrong one hands your account to another phone. "Move off
  this phone" and "Take over from another phone" are now boxes with
  names rather than halves of a page.
- Coordinators reads as three answers: what you use, what is on offer,
  and the manual escape hatch. Pasting a 64-character key belongs last,
  as the fallback it became once discovery went in above it.
- Backup's passphrase governs both export and restore. It sat above two
  unrelated-looking blocks and never said so; it is its own section now.
- The link screen separates asking from answering.
- Key packages gets one section rather than one per coordinator, because
  SettingsSection titles come from a StringResource and a coordinator's
  label is runtime text. Its dividers move inside and are drawn between
  peers rather than after the last one, which used to rule off to
  nothing.

SettingsFormBlock is the one new piece: the section card has no padding
of its own, by design, because it is built for rows like SettingsItem
that carry theirs so a divider can run edge to edge. Form content needs
that inset back, and one helper keeps five screens identical to each
other as they change.

Two strings retired: the in-body "Add a coordinator" and "Coordinators
announcing themselves" titles are section headers now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-24 04:02:20 +00:00
parent 0b8b2c0c1c
commit d46d898f4d
8 changed files with 242 additions and 124 deletions
@@ -32,9 +32,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -56,11 +53,15 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_export
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_passphrase
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_restore
import com.vitorpamplona.amethyst.commons.resources.cordn_backup_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -191,57 +192,66 @@ fun CordnBackupScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = passphrase,
onValueChange = {
passphrase = it
error = null
},
label = { Text(stringRes(R.string.cordn_backup_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.padding(12.dp)) {
Text(stringRes(R.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall)
Text(stringRes(R.string.cordn_backup_contents_body), style = MaterialTheme.typography.bodySmall)
// The passphrase is its own section because it governs both of the
// two below: the same word exports and restores, and a field
// floating above two unrelated-looking cards did not say so.
SettingsSection(Res.string.cordn_backup_section_passphrase) {
SettingsFormBlock {
OutlinedTextField(
value = passphrase,
onValueChange = {
passphrase = it
error = null
},
label = { Text(stringRes(R.string.cordn_backup_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Button(
onClick = { saver.launch("cordn-backup.bin") },
// No passphrase, no export. The file carries ratchet trees and
// private key material; there is no version of it that is safe
// to write unprotected.
enabled = !busy && passphrase.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
// Key derivation here is scrypt, which is slow on purpose, so
// a greyed-out button was the only sign anything was happening
// for several seconds.
BusyLabel(busy, stringRes(R.string.cordn_backup_export))
SettingsSection(Res.string.cordn_backup_section_export) {
SettingsFormBlock {
Text(stringRes(R.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall)
Text(
text = stringRes(R.string.cordn_backup_contents_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { saver.launch("cordn-backup.bin") },
// No passphrase, no export. The file carries ratchet
// trees and private key material; there is no version
// of it that is safe to write unprotected.
enabled = !busy && passphrase.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
// Key derivation here is scrypt, which is slow on
// purpose, so a greyed-out button was the only sign
// anything was happening for several seconds.
BusyLabel(busy, stringRes(R.string.cordn_backup_export))
}
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SettingsSection(Res.string.cordn_backup_section_restore) {
SettingsFormBlock {
Text(
text = stringRes(R.string.cordn_backup_restore_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(stringRes(R.string.cordn_backup_restore_title), style = MaterialTheme.typography.titleSmall)
Text(
text = stringRes(R.string.cordn_backup_restore_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { picker.launch("*/*") },
enabled = !busy && passphrase.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
BusyLabel(busy, stringRes(R.string.cordn_backup_restore))
Button(
onClick = { picker.launch("*/*") },
enabled = !busy && passphrase.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
BusyLabel(busy, stringRes(R.string.cordn_backup_restore))
}
}
}
}
}
@@ -36,7 +36,6 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -60,6 +59,9 @@ import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery
import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_discover
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_manual
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_yours
import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -68,6 +70,7 @@ import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -136,25 +139,39 @@ fun CordnCoordinatorsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
coordinators.forEach { config ->
CoordinatorCard(config, runtime)
// Three sections rather than one column split by rules: what you
// already use, what is on offer, and the manual escape hatch. The
// dividers said "something else follows" without saying what.
SettingsSection(Res.string.cordn_coordinators_section_yours) {
SettingsFormBlock {
coordinators.forEach { config ->
CoordinatorCard(config, runtime)
}
if (coordinators.isEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (coordinators.isEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SettingsSection(Res.string.cordn_coordinators_section_discover) {
SettingsFormBlock {
DiscoverCoordinators(runtime, accountViewModel, coordinators.map { it.pubKey }.toSet())
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
DiscoverCoordinators(runtime, accountViewModel, coordinators.map { it.pubKey }.toSet())
HorizontalDivider(Modifier.padding(vertical = 8.dp))
AddCoordinator(runtime)
// Last, and it reads as the fallback it is now that discovery is
// above it: pasting a 64-character key is what you do when nobody
// announced the one you were told to use.
SettingsSection(Res.string.cordn_coordinators_section_manual) {
SettingsFormBlock {
AddCoordinator(runtime)
}
}
}
}
}
@@ -308,7 +325,6 @@ private fun DiscoverCoordinators(
var error by remember { mutableStateOf<String?>(null) }
val failed = stringRes(R.string.cordn_coordinators_discover_failed)
Text(stringRes(R.string.cordn_coordinators_discover), style = MaterialTheme.typography.titleSmall)
Text(
text = stringRes(R.string.cordn_coordinators_discover_explainer),
style = MaterialTheme.typography.bodySmall,
@@ -446,8 +462,6 @@ private fun AddCoordinator(runtime: CordnRuntime) {
}
}
Text(stringRes(R.string.cordn_coordinators_add), style = MaterialTheme.typography.titleSmall)
OutlinedTextField(
value = pubKeyInput,
onValueChange = {
@@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_section
import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.model.cordn.CordnKeyPackageRow
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
@@ -128,17 +130,29 @@ fun CordnKeyPackagesScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (coordinators.isEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// One section holding every coordinator rather than one each:
// SettingsSection titles come from a StringResource and a
// coordinator's label is runtime text, so a section per
// coordinator is not something this primitive can say. The
// dividers stay, inside, where they now separate peers within a
// box instead of floating in open page.
SettingsSection(Res.string.cordn_keypackages_section) {
SettingsFormBlock {
if (coordinators.isEmpty()) {
Text(
text = stringRes(R.string.cordn_coordinators_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
coordinators.forEach { config ->
CoordinatorKeyPackages(config, runtime)
HorizontalDivider()
coordinators.forEachIndexed { index, config ->
// Between, not after: a rule under the last one drew a
// line to nothing.
if (index > 0) HorizontalDivider()
CoordinatorKeyPackages(config, runtime)
}
}
}
}
}
@@ -60,11 +60,13 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_link_no_coordinator
import com.vitorpamplona.amethyst.commons.resources.cordn_link_not_joinable
import com.vitorpamplona.amethyst.commons.resources.cordn_link_paste
import com.vitorpamplona.amethyst.commons.resources.cordn_link_relays
import com.vitorpamplona.amethyst.commons.resources.cordn_link_section_input
import com.vitorpamplona.amethyst.commons.resources.cordn_link_title
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -122,49 +124,56 @@ fun CordnLinkScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
// Clearing on edit rather than re-parsing per keystroke: a
// half-typed ref is always invalid, and showing that while
// someone is still pasting is noise, not feedback.
inspection = null
requestState = null
},
label = { Text(stringResource(Res.string.cordn_link_field)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { inspection = CordnLinkInspection.of(input) },
enabled = input.isNotBlank(),
) {
Text(stringResource(Res.string.cordn_link_inspect))
}
OutlinedButton(
onClick = {
clipboard.getText()?.text?.let {
// The field, its buttons and the scanner are one thing to do; the
// verdict below is another. Boxing the input says where the screen
// asks something of you and where it answers.
SettingsSection(Res.string.cordn_link_section_input) {
SettingsFormBlock {
OutlinedTextField(
value = input,
onValueChange = {
input = it
inspection = CordnLinkInspection.of(it)
}
},
) {
Text(stringResource(Res.string.cordn_link_paste))
}
OutlinedButton(onClick = { scanning = true }) {
Text(stringRes(R.string.cordn_link_scan))
}
if (input.isNotEmpty()) {
OutlinedButton(
onClick = {
input = ""
// Clearing on edit rather than re-parsing per keystroke: a
// half-typed ref is always invalid, and showing that while
// someone is still pasting is noise, not feedback.
inspection = null
requestState = null
},
) {
Text(stringResource(Res.string.cordn_link_clear))
label = { Text(stringResource(Res.string.cordn_link_field)) },
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { inspection = CordnLinkInspection.of(input) },
enabled = input.isNotBlank(),
) {
Text(stringResource(Res.string.cordn_link_inspect))
}
OutlinedButton(
onClick = {
clipboard.getText()?.text?.let {
input = it
inspection = CordnLinkInspection.of(it)
}
},
) {
Text(stringResource(Res.string.cordn_link_paste))
}
OutlinedButton(onClick = { scanning = true }) {
Text(stringRes(R.string.cordn_link_scan))
}
if (input.isNotEmpty()) {
OutlinedButton(
onClick = {
input = ""
inspection = null
},
) {
Text(stringResource(Res.string.cordn_link_clear))
}
}
}
}
}
@@ -31,7 +31,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -71,6 +70,8 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_receive
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_replaces_warning
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan_this
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_receive
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_send
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_sign_in_first
import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_title
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
import kotlinx.coroutines.launch
@@ -146,11 +148,21 @@ fun CordnMigrateScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SendSide(runtime, accountViewModel)
// Two boxes, because they are two different devices' jobs and
// only one of them is yours today. A rule between them said they
// were separate without saying which was which — and doing the
// wrong one here hands your account to another phone.
SettingsSection(Res.string.cordn_migrate_section_send) {
SettingsFormBlock {
SendSide(runtime, accountViewModel)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
ReceiveSide(runtime, accountViewModel)
SettingsSection(Res.string.cordn_migrate_section_receive) {
SettingsFormBlock {
ReceiveSide(runtime, accountViewModel)
}
}
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Form content inside a `SettingsSection`.
*
* The section's card deliberately has no padding of its own: it is built for
* rows like `SettingsItem`, which carry theirs so a divider can run edge to
* edge between them. cordn's pages are forms rather than row lists — text
* fields, explainers and buttons — and those need the inset the rows would
* otherwise have supplied.
*
* One helper rather than a `Modifier.padding(16.dp)` repeated down five
* screens, so the pages stay identical to each other as they change.
*/
@Composable
fun SettingsFormBlock(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
content = content,
)
}
-2
View File
@@ -367,7 +367,6 @@
<string name="cordn_invitations_decline_warning">Declining is permanent. Getting back in means being invited again.</string>
<string name="cordn_chat_yesterday">Yesterday</string>
<string name="cordn_group_unavailable_detail">Add a coordinator in Settings to start using cordn groups.</string>
<string name="cordn_coordinators_discover">Coordinators announcing themselves</string>
<string name="cordn_coordinators_discover_explainer">Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked.</string>
<string name="cordn_coordinators_discover_action">Look for coordinators</string>
<string name="cordn_coordinators_discover_add">Add</string>
@@ -402,7 +401,6 @@
<string name="cordn_coordinators_explainer">A coordinator orders one group\'s messages and is the only thing that can. Losing it loses the ordering, and a second one does not mirror the first — so these are named things you choose, not interchangeable relays.</string>
<string name="cordn_coordinators_none">No coordinators yet.</string>
<string name="cordn_coordinators_unnamed">Unnamed coordinator</string>
<string name="cordn_coordinators_add">Add a coordinator</string>
<string name="cordn_coordinators_add_action">Add</string>
<string name="cordn_coordinators_add_failed">Could not open that coordinator.</string>
<string name="cordn_coordinators_label">A name for it (yours, optional)</string>
@@ -5401,6 +5401,16 @@
<string name="cordn_hub_explainer">Groups delivered by a coordinator you choose, instead of by relays.</string>
<string name="cordn_hub_link">Inspect a group link</string>
<string name="cordn_hub_link_desc">See what a cordn1… link points at before you act on it</string>
<string name="cordn_migrate_section_send">Move off this phone</string>
<string name="cordn_migrate_section_receive">Take over from another phone</string>
<string name="cordn_keypackages_section">Published key packages</string>
<string name="cordn_link_section_input">The link</string>
<string name="cordn_coordinators_section_yours">Coordinators you use</string>
<string name="cordn_coordinators_section_discover">Announcing themselves</string>
<string name="cordn_coordinators_section_manual">Add one by hand</string>
<string name="cordn_backup_section_passphrase">Passphrase</string>
<string name="cordn_backup_section_export">Make a backup</string>
<string name="cordn_backup_section_restore">Restore from a backup</string>
<string name="cordn_hub_section_service">The coordinator</string>
<string name="cordn_hub_section_device">This device</string>
<string name="cordn_hub_coordinators">Coordinators</string>