From e465797b5f8c586f6b17e301d61019c91a95fb9b Mon Sep 17 00:00:00 2001
From: Denozordec
Date: Mon, 12 Oct 2026 10:51:13 +0700
Subject: [PATCH] android: certificates screen as in the Final 2026 mockup
(from profiles, added, system; expiry colors)
Co-Authored-By: Claude Opus 5.5
---
.../android/ui/TrustedCertificatesActivity.kt | 308 ++++++++++++------
.../android/ui/compose/SettingsScreen.kt | 14 +-
.../app/src/main/res/values-ru/strings.xml | 16 +-
.../app/src/main/res/values/strings.xml | 16 +-
4 files changed, 260 insertions(+), 94 deletions(-)
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificatesActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificatesActivity.kt
index 399667fa3..d3d081d2d 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificatesActivity.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificatesActivity.kt
@@ -9,6 +9,7 @@
package org.strongswan.android.ui
+import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -16,24 +17,23 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
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.Row
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.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
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.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -42,22 +42,40 @@ 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.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
+import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
import org.strongswan.android.security.TrustedCertificateEntry
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.ShxType
import java.beans.PropertyChangeListener
import java.security.KeyStore
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.TimeUnit
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)
+
+/* 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>
+{
+ 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
+{
+ 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
private fun CertificatesScreen(
selectMode: Boolean,
@@ -95,28 +149,28 @@ private fun CertificatesScreen(
)
{
val context = LocalContext.current
- val sources = remember {
- 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),
- )
- }
+ val c = LocalShxColors.current
var tab by remember { mutableIntStateOf(0) }
- var entries by remember { mutableStateOf(listOf()) }
+ var rows by remember { mutableStateOf(listOf()) }
+ var profileCount by remember { mutableIntStateOf(0) }
var pendingDelete by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
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()
{
- 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) {
@@ -126,83 +180,155 @@ private fun CertificatesScreen(
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
}
- SecondaryScaffold(
- title = stringResource(R.string.trusted_certs_title),
- onBack = onBack,
- actions = {
+ SecondaryScaffold(title = stringResource(R.string.trusted_certs_title), onBack = onBack) { padding ->
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ 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)
{
- IconButton(onClick = {
- importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java))
- }) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) }
- }
- IconButton(onClick = {
- scope.launch {
- withContext(Dispatchers.IO) {
- TrustedCertificateManager.getInstance().reset().load()
- }
- reload(sources[tab].first) { entries = it }
- }
- }) { 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
- },
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp))
+ .border(1.dp, c.outline, RoundedCornerShape(18.dp))
+ .clickable(role = Role.Button) { importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) },
+ )
+ {
+ Icon(ShxIcons.Plus, contentDescription = null, tint = c.text, modifier = Modifier.size(18.dp))
+ Text(stringResource(R.string.certs_add), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
}
}
+ 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 ->
- AlertDialog(
- onDismissRequest = { pendingDelete = null },
- title = { Text(stringResource(R.string.delete_certificate_question)) },
- text = { Text(entry.subjectPrimary) },
- confirmButton = {
- TextButton(onClick = {
- try
- {
- val store = KeyStore.getInstance("LocalCertificateStore")
- store.load(null, null)
- store.deleteEntry(entry.alias)
- 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))
+ ShxDialog(
+ title = stringResource(R.string.delete_certificate_question),
+ paragraphs = listOf(entry.subjectPrimary),
+ confirm = stringResource(R.string.server_delete_confirm),
+ dismiss = stringResource(android.R.string.cancel),
+ onConfirm = {
+ try
+ {
+ val store = KeyStore.getInstance("LocalCertificateStore")
+ store.load(null, null)
+ store.deleteEntry(entry.alias)
+ TrustedCertificateManager.getInstance().reset()
}
+ catch (_: Exception)
+ {
+ }
+ pendingDelete = null
+ load()
},
+ onDismiss = { pendingDelete = null },
+ icon = ShxIcons.Trash,
+ danger = true,
)
}
}
-private suspend fun reload(source: TrustedCertificateSource, onResult: (List) -> Unit)
+@Composable
+private fun CertItem(row: CertRow, onClick: () -> Unit)
{
- val list = withContext(Dispatchers.IO) {
- val manager = TrustedCertificateManager.getInstance().load()
- manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) }
- .sorted()
+ val c = LocalShxColors.current
+ val notAfter = row.entry.certificate.notAfter
+ val days = TimeUnit.MILLISECONDS.toDays(notAfter.time - System.currentTimeMillis())
+ 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, 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
}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
index 3d041dba7..690cb43f6 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
@@ -97,6 +97,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
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 androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement
@@ -760,6 +763,10 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
var showKillSwitchDialog by remember { mutableStateOf(false) }
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(
stringResource(R.string.settings_section_automation) to listOf(
@@ -817,7 +824,12 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
},
SettingsEntry(
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,
) { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) },
),
diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml
index a60dc6424..59b58267d 100644
--- a/src/frontends/android/app/src/main/res/values-ru/strings.xml
+++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml
@@ -330,7 +330,7 @@
Trusted Network Connect (TNC) allows server operators to assess the health of a client device.
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.
Any data will be sent only after verifying the server\'s identity.]]>
- Сертификаты CA
+ Сертификаты
Нет доступных сертификатов
Обновить сертификат CA
Система
@@ -1075,4 +1075,18 @@
Звук и вид — в настройках Android
Подсказки
Предложения, например пустить новое приложение напрямую
+ Нужны, чтобы убедиться, что вы подключаетесь к настоящему серверу. Обычно приходят вместе с профилем — трогать ничего не нужно.
+ Из профилей · %1$d
+ Добавленные
+ Системные
+ используют: %1$s
+ действует до %1$s
+ истекает через %1$d дн. — %2$s
+ истёк %1$s
+ Добавить сертификат (.crt, .pem)
+ Системные — те, которым доверяет Android.
+ Профили выбирают сертификат автоматически.
+ Здесь сертификатов нет.
+ %1$d из профилей
+ %1$d из профилей · %2$d скоро истекает
diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml
index 01a9e6103..95b4f0c20 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -330,7 +330,7 @@
Trusted Network Connect (TNC) allows server operators to assess the health of a client device.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.
Any data will be sent only after verifying the server\'s identity.]]>
- CA certificates
+ Certificates
No certificates
Reload CA certificates
System
@@ -1061,4 +1061,18 @@
Sound and look — in Android settings
Hints
Suggestions such as letting a new app go direct
+ They prove you connect to the real server. They usually come with the profile — nothing to do here.
+ From profiles · %1$d
+ Added
+ System
+ used by: %1$s
+ valid until %1$s
+ expires in %1$d days — %2$s
+ expired %1$s
+ Add certificate (.crt, .pem)
+ System ones are those Android trusts.
+ The profiles pick the certificate automatically.
+ No certificates here.
+ %1$d from profiles
+ %1$d from profiles · %2$d expires soon