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
+249 -83

No files matched your search

@@ -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<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
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<TrustedCertificateEntry>()) }
var rows by remember { mutableStateOf(listOf<CertRow>()) }
var profileCount by remember { mutableIntStateOf(0) }
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(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,54 +180,79 @@ private fun CertificatesScreen(
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
}
SecondaryScaffold(
title = stringResource(R.string.trusted_certs_title),
onBack = onBack,
actions = {
if (allowImport)
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),
)
{
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
},
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)
{
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 = {
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")
@@ -186,23 +265,70 @@ private fun CertificatesScreen(
}
pendingDelete = null
load()
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
}
},
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 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<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.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)) },
),
@@ -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>
<!-- Trusted certificate selection -->
<string name="trusted_certs_title">Сертификаты CA</string>
<string name="trusted_certs_title">Сертификаты</string>
<string name="no_certificates">Нет доступных сертификатов</string>
<string name="reload_trusted_certs">Обновить сертификат CA</string>
<string name="system_tab">Система</string>
@@ -1075,4 +1075,18 @@
<string name="notif_android">Звук и вид — в настройках Android</string>
<string name="events_channel_hints">Подсказки</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>
@@ -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>
<!-- 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="reload_trusted_certs">Reload CA certificates</string>
<string name="system_tab">System</string>
@@ -1061,4 +1061,18 @@
<string name="notif_android">Sound and look — in Android settings</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="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>