android: certificates screen as in the Final 2026 mockup (from profiles, added, system; expiry colors)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:51:13 +07:00
1 parent b4a311c84f
commit e465797b5f
4 files changed
+260 -94

No files matched your search

@@ -9,6 +9,7 @@
package org.strongswan.android.ui package org.strongswan.android.ui
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -16,24 +17,23 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Add import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -42,22 +42,40 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.StrongSwanApplication import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
import org.strongswan.android.security.TrustedCertificateEntry import org.strongswan.android.security.TrustedCertificateEntry
import org.strongswan.android.ui.compose.SecondaryScaffold import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.design.ShxDialog
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import java.security.KeyStore import java.security.KeyStore
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
class TrustedCertificatesActivity : AppCompatActivity() class TrustedCertificatesActivity : AppCompatActivity()
{ {
@@ -86,7 +104,43 @@ class TrustedCertificatesActivity : AppCompatActivity()
} }
} }
@OptIn(ExperimentalMaterial3Api::class) /** A CA certificate as a row of F_Certs: who uses it and how long it is valid. */
private data class CertRow(val entry: TrustedCertificateEntry, val usedBy: List<String>)
/* the three tabs of F_Certs */
private enum class CertTab { PROFILES, ADDED, SYSTEM }
/** Days a certificate counts as «expires soon» (amber). */
internal const val CERT_SOON_DAYS = 30L
/**
* The CA certificates the profiles point to, with the names of those
* profiles. Profiles that pick the CA automatically are not listed.
*/
internal fun profileCertificates(context: Context): Map<String, List<String>>
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.allVpnProfiles.mapNotNull { p -> p.certificateAlias?.let { it to (p.name ?: p.gateway) } }
.groupBy({ it.first }, { it.second })
}
finally
{
source.close()
}
}
/** For the Settings list: how many CAs come from profiles and how many of them expire soon. */
internal fun certificateSummary(context: Context): Pair<Int, Int>
{
val manager = TrustedCertificateManager.getInstance().load()
val certs = profileCertificates(context).keys.mapNotNull { manager.getCACertificateFromAlias(it) }
val soon = certs.count { TimeUnit.MILLISECONDS.toDays(it.notAfter.time - System.currentTimeMillis()) <= CERT_SOON_DAYS }
return certs.size to soon
}
@Composable @Composable
private fun CertificatesScreen( private fun CertificatesScreen(
selectMode: Boolean, selectMode: Boolean,
@@ -95,28 +149,28 @@ private fun CertificatesScreen(
) )
{ {
val context = LocalContext.current val context = LocalContext.current
val sources = remember { val c = LocalShxColors.current
listOf(
TrustedCertificateSource.SYSTEM to context.getString(R.string.system_tab),
TrustedCertificateSource.USER to context.getString(R.string.user_tab),
TrustedCertificateSource.LOCAL to context.getString(R.string.local_tab),
)
}
var tab by remember { mutableIntStateOf(0) } var tab by remember { mutableIntStateOf(0) }
var entries by remember { mutableStateOf(listOf<TrustedCertificateEntry>()) } var rows by remember { mutableStateOf(listOf<CertRow>()) }
var profileCount by remember { mutableIntStateOf(0) }
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) } var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == android.app.Activity.RESULT_OK)
{
scope.launch { reload(sources[tab].first) { entries = it } }
}
}
fun load() fun load()
{ {
scope.launch { reload(sources[tab].first) { entries = it } } scope.launch {
val (list, count) = withContext(Dispatchers.IO) { loadRows(context, CertTab.entries[tab]) }
rows = list
profileCount = count
}
}
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == android.app.Activity.RESULT_OK)
{
load()
}
} }
DisposableEffect(tab) { DisposableEffect(tab) {
@@ -126,83 +180,155 @@ private fun CertificatesScreen(
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) } onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
} }
SecondaryScaffold( SecondaryScaffold(title = stringResource(R.string.trusted_certs_title), onBack = onBack) { padding ->
title = stringResource(R.string.trusted_certs_title), Column(
onBack = onBack, verticalArrangement = Arrangement.spacedBy(12.dp),
actions = { modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
)
{
Text(
stringResource(R.string.certs_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
)
ShxTabs(
listOf(
stringResource(R.string.certs_tab_profiles, profileCount),
stringResource(R.string.certs_tab_added),
stringResource(R.string.certs_tab_system),
),
tab, { tab = it },
)
if (rows.isEmpty())
{
Text(
stringResource(if (tab == 0) R.string.certs_none_profiles else R.string.certs_none),
style = ShxType.sub, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
)
}
else
{
ShxPanel()
{
rows.forEachIndexed { i, row ->
CertItem(row, onClick = {
when
{
selectMode -> onPicked(row.entry.alias)
CertTab.entries[tab] == CertTab.ADDED -> pendingDelete = row.entry
}
})
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
if (allowImport) if (allowImport)
{ {
IconButton(onClick = { Row(
importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) verticalAlignment = Alignment.CenterVertically,
}) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) } horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
} modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp))
IconButton(onClick = { .border(1.dp, c.outline, RoundedCornerShape(18.dp))
scope.launch { .clickable(role = Role.Button) { importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) },
withContext(Dispatchers.IO) { )
TrustedCertificateManager.getInstance().reset().load() {
} Icon(ShxIcons.Plus, contentDescription = null, tint = c.text, modifier = Modifier.size(18.dp))
reload(sources[tab].first) { entries = it } Text(stringResource(R.string.certs_add), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
}
}) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reload_trusted_certs)) }
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
SecondaryTabRow(selectedTabIndex = tab) {
sources.forEachIndexed { index, pair ->
Tab(selected = tab == index, onClick = { tab = index }, text = { Text(pair.second) })
}
}
LazyColumn(Modifier.fillMaxSize()) {
items(entries, key = { it.alias }) { entry ->
ListItem(
headlineContent = { Text(entry.subjectPrimary) },
supportingContent = { Text(entry.subjectSecondary.ifEmpty { entry.alias }) },
modifier = Modifier.clickable {
if (selectMode) onPicked(entry.alias)
else if (sources[tab].first == TrustedCertificateSource.LOCAL) pendingDelete = entry
},
)
} }
} }
Text(
stringResource(R.string.certs_system_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp),
color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp),
)
} }
} }
pendingDelete?.let { entry -> pendingDelete?.let { entry ->
AlertDialog( ShxDialog(
onDismissRequest = { pendingDelete = null }, title = stringResource(R.string.delete_certificate_question),
title = { Text(stringResource(R.string.delete_certificate_question)) }, paragraphs = listOf(entry.subjectPrimary),
text = { Text(entry.subjectPrimary) }, confirm = stringResource(R.string.server_delete_confirm),
confirmButton = { dismiss = stringResource(android.R.string.cancel),
TextButton(onClick = { onConfirm = {
try try
{ {
val store = KeyStore.getInstance("LocalCertificateStore") val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null) store.load(null, null)
store.deleteEntry(entry.alias) store.deleteEntry(entry.alias)
TrustedCertificateManager.getInstance().reset() TrustedCertificateManager.getInstance().reset()
}
catch (_: Exception)
{
}
pendingDelete = null
load()
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
} }
catch (_: Exception)
{
}
pendingDelete = null
load()
}, },
onDismiss = { pendingDelete = null },
icon = ShxIcons.Trash,
danger = true,
) )
} }
} }
private suspend fun reload(source: TrustedCertificateSource, onResult: (List<TrustedCertificateEntry>) -> Unit) @Composable
private fun CertItem(row: CertRow, onClick: () -> Unit)
{ {
val list = withContext(Dispatchers.IO) { val c = LocalShxColors.current
val manager = TrustedCertificateManager.getInstance().load() val notAfter = row.entry.certificate.notAfter
manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) } val days = TimeUnit.MILLISECONDS.toDays(notAfter.time - System.currentTimeMillis())
.sorted() val date = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(notAfter)
/* green while valid, amber when it expires soon, red when expired */
val (bg, fg, expColor, expText) = when
{
days < 0 -> Look(c.errorContainer, c.error, c.error, stringResource(R.string.certs_expired, date))
days <= CERT_SOON_DAYS -> Look(c.warningStrong, c.onWarning, c.warning, stringResource(R.string.certs_expires_in, days.toInt(), date))
else -> Look(c.successContainer, c.onSuccess, c.onSuccess, stringResource(R.string.certs_valid_until, SimpleDateFormat("MM.yyyy", Locale.getDefault()).format(notAfter)))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(bg))
{
Icon(ShxIcons.Cert, contentDescription = null, tint = fg, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(row.entry.subjectPrimary, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
val used = if (row.usedBy.isNotEmpty()) stringResource(R.string.certs_used_by, row.usedBy.joinToString(", "))
else row.entry.subjectSecondary
if (used.isNotEmpty())
{
Text(used, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
Text(expText, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = expColor)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
} }
onResult(list) }
private data class Look(val bg: Color, val fg: Color, val exp: Color, val text: String)
/** Rows of [tab] and the number of CAs used by profiles (shown on the first tab). */
private fun loadRows(context: Context, tab: CertTab): Pair<List<CertRow>, Int>
{
val manager = TrustedCertificateManager.getInstance().load()
val usage = profileCertificates(context)
val fromProfiles = usage.mapNotNull { (alias, names) ->
manager.getCACertificateFromAlias(alias)?.let { CertRow(TrustedCertificateEntry(alias, it), names) }
}.sortedBy { it.entry }
val rows = when (tab)
{
CertTab.PROFILES -> fromProfiles
CertTab.ADDED -> manager.getCACertificates(TrustedCertificateSource.LOCAL)
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
CertTab.SYSTEM -> (manager.getCACertificates(TrustedCertificateSource.SYSTEM) + manager.getCACertificates(TrustedCertificateSource.USER))
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
}
return rows to fromProfiles.size
} }
@@ -97,6 +97,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.ui.compose.components.PredictiveBackScale import org.strongswan.android.ui.compose.components.PredictiveBackScale
import androidx.compose.runtime.produceState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R import org.strongswan.android.R
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -760,6 +763,10 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle() val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
var showKillSwitchDialog by remember { mutableStateOf(false) } var showKillSwitchDialog by remember { mutableStateOf(false) }
val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) } val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) }
/* «2 from profiles · 1 expires soon» */
val certSummary by produceState(0 to 0) {
value = withContext(Dispatchers.IO) { runCatching { org.strongswan.android.ui.certificateSummary(context) }.getOrDefault(0 to 0) }
}
val groups = listOf( val groups = listOf(
stringResource(R.string.settings_section_automation) to listOf( stringResource(R.string.settings_section_automation) to listOf(
@@ -817,7 +824,12 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
}, },
SettingsEntry( SettingsEntry(
stringResource(R.string.trusted_certs_title), stringResource(R.string.trusted_certs_title),
pluralStringResource(R.plurals.settings_certs_n, certs, certs), when
{
certSummary.second > 0 -> stringResource(R.string.settings_certs_soon, certSummary.first, certSummary.second)
certSummary.first > 0 -> stringResource(R.string.settings_certs_profiles, certSummary.first)
else -> pluralStringResource(R.plurals.settings_certs_n, certs, certs)
},
ShxIcons.Cert, ShxIcons.Cert,
) { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) }, ) { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) },
), ),
@@ -330,7 +330,7 @@
<string name="tnc_notice_details"><![CDATA[<p>Trusted Network Connect (TNC) allows server operators to assess the health of a client device.</p><p>For that purpose the server operator may request data such as a unique identifier, a list of installed packages, system settings, or cryptographic checksums of files.</p><b>Any data will be sent only after verifying the server\'s identity.</b>]]></string> <string name="tnc_notice_details"><![CDATA[<p>Trusted Network Connect (TNC) allows server operators to assess the health of a client device.</p><p>For that purpose the server operator may request data such as a unique identifier, a list of installed packages, system settings, or cryptographic checksums of files.</p><b>Any data will be sent only after verifying the server\'s identity.</b>]]></string>
<!-- Trusted certificate selection --> <!-- Trusted certificate selection -->
<string name="trusted_certs_title">Сертификаты CA</string> <string name="trusted_certs_title">Сертификаты</string>
<string name="no_certificates">Нет доступных сертификатов</string> <string name="no_certificates">Нет доступных сертификатов</string>
<string name="reload_trusted_certs">Обновить сертификат CA</string> <string name="reload_trusted_certs">Обновить сертификат CA</string>
<string name="system_tab">Система</string> <string name="system_tab">Система</string>
@@ -1075,4 +1075,18 @@
<string name="notif_android">Звук и вид — в настройках Android</string> <string name="notif_android">Звук и вид — в настройках Android</string>
<string name="events_channel_hints">Подсказки</string> <string name="events_channel_hints">Подсказки</string>
<string name="events_channel_hints_desc">Предложения, например пустить новое приложение напрямую</string> <string name="events_channel_hints_desc">Предложения, например пустить новое приложение напрямую</string>
<string name="certs_intro">Нужны, чтобы убедиться, что вы подключаетесь к настоящему серверу. Обычно приходят вместе с профилем — трогать ничего не нужно.</string>
<string name="certs_tab_profiles">Из профилей · %1$d</string>
<string name="certs_tab_added">Добавленные</string>
<string name="certs_tab_system">Системные</string>
<string name="certs_used_by">используют: %1$s</string>
<string name="certs_valid_until">действует до %1$s</string>
<string name="certs_expires_in">истекает через %1$d дн. — %2$s</string>
<string name="certs_expired">истёк %1$s</string>
<string name="certs_add">Добавить сертификат (.crt, .pem)</string>
<string name="certs_system_note">Системные — те, которым доверяет Android.</string>
<string name="certs_none_profiles">Профили выбирают сертификат автоматически.</string>
<string name="certs_none">Здесь сертификатов нет.</string>
<string name="settings_certs_profiles">%1$d из профилей</string>
<string name="settings_certs_soon">%1$d из профилей · %2$d скоро истекает</string>
</resources> </resources>
@@ -330,7 +330,7 @@
<string name="tnc_notice_details"><![CDATA[<p>Trusted Network Connect (TNC) allows server operators to assess the health of a client device.</p><p>For that purpose the server operator may request data such as a unique identifier, a list of installed packages, system settings, or cryptographic checksums of files.</p><b>Any data will be sent only after verifying the server\'s identity.</b>]]></string> <string name="tnc_notice_details"><![CDATA[<p>Trusted Network Connect (TNC) allows server operators to assess the health of a client device.</p><p>For that purpose the server operator may request data such as a unique identifier, a list of installed packages, system settings, or cryptographic checksums of files.</p><b>Any data will be sent only after verifying the server\'s identity.</b>]]></string>
<!-- Trusted certificate selection --> <!-- Trusted certificate selection -->
<string name="trusted_certs_title">CA certificates</string> <string name="trusted_certs_title">Certificates</string>
<string name="no_certificates">No certificates</string> <string name="no_certificates">No certificates</string>
<string name="reload_trusted_certs">Reload CA certificates</string> <string name="reload_trusted_certs">Reload CA certificates</string>
<string name="system_tab">System</string> <string name="system_tab">System</string>
@@ -1061,4 +1061,18 @@
<string name="notif_android">Sound and look — in Android settings</string> <string name="notif_android">Sound and look — in Android settings</string>
<string name="events_channel_hints">Hints</string> <string name="events_channel_hints">Hints</string>
<string name="events_channel_hints_desc">Suggestions such as letting a new app go direct</string> <string name="events_channel_hints_desc">Suggestions such as letting a new app go direct</string>
<string name="certs_intro">They prove you connect to the real server. They usually come with the profile — nothing to do here.</string>
<string name="certs_tab_profiles">From profiles · %1$d</string>
<string name="certs_tab_added">Added</string>
<string name="certs_tab_system">System</string>
<string name="certs_used_by">used by: %1$s</string>
<string name="certs_valid_until">valid until %1$s</string>
<string name="certs_expires_in">expires in %1$d days — %2$s</string>
<string name="certs_expired">expired %1$s</string>
<string name="certs_add">Add certificate (.crt, .pem)</string>
<string name="certs_system_note">System ones are those Android trusts.</string>
<string name="certs_none_profiles">The profiles pick the certificate automatically.</string>
<string name="certs_none">No certificates here.</string>
<string name="settings_certs_profiles">%1$d from profiles</string>
<string name="settings_certs_soon">%1$d from profiles · %2$d expires soon</string>
</resources> </resources>