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