diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index d14fe7d1b..3d85448be 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -118,6 +118,12 @@ import kotlinx.coroutines.flow.StateFlow import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.produceState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.strongswan.android.ui.compose.home.CertExpiry +import org.strongswan.android.ui.compose.home.certExpiry +import org.strongswan.android.ui.compose.home.snoozeCertExpiry import org.strongswan.android.R import androidx.compose.foundation.layout.widthIn import org.strongswan.android.logic.routing.RuIpListManager @@ -243,6 +249,12 @@ fun HomeScreen( val problem = if (pauseActive && pauseUntil > now) null else homeProblem(state, networkAvailable) val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE val lockdown = remember { context.isVpnLockdownEnabled() } + /* F_CertExpiry: the CA of the shown profile runs out soon */ + val certAlias = (state.activeProfile ?: selected)?.certificateAlias + var certSnoozes by remember { mutableStateOf(0) } + val certExpiry by produceState(null, certAlias, certSnoozes) { + value = withContext(Dispatchers.IO) { certExpiry(context, certAlias) } + } val model = HomeModel( state = state, traffic = traffic, @@ -257,6 +269,7 @@ fun HomeScreen( networkKinds = networkKinds, unread = unreadEvents, findingFastest = findingFastest, + certExpiry = certExpiry, ) val actions = HomeActions( connect = { selected?.let { viewModel.connect(context, it) } }, @@ -297,6 +310,11 @@ fun HomeScreen( } }, details = { viewModel.openDetails(true) }, + certUpdate = { ShxNavigation.requestAddServer() }, + certLater = { + certAlias?.let { snoozeCertExpiry(context, it) } + certSnoozes++ + }, ) LazyColumn( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/CertExpiry.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/CertExpiry.kt new file mode 100644 index 000000000..6c2803b43 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/CertExpiry.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + +package org.strongswan.android.ui.compose.home + +import android.content.Context +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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 androidx.preference.PreferenceManager +import org.strongswan.android.R +import org.strongswan.android.logic.TrustedCertificateManager +import org.strongswan.android.ui.CERT_SOON_DAYS +import org.strongswan.android.ui.compose.theme.LocalShxColors +import org.strongswan.android.ui.compose.theme.ShxType +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** The CA certificate of the current profile runs out soon (F_CertExpiry). */ +internal data class CertExpiry(val alias: String, val days: Long, val notAfter: Long) + +private const val SNOOZE_PREFIX = "pref_cert_snooze_" +private const val SNOOZE_MS = 24L * 60 * 60 * 1000 + +/** The expiry of [alias] when it is within [CERT_SOON_DAYS] and not snoozed; blocking. */ +internal fun certExpiry(context: Context, alias: String?): CertExpiry? +{ + alias ?: return null + val now = System.currentTimeMillis() + if (PreferenceManager.getDefaultSharedPreferences(context).getLong(SNOOZE_PREFIX + alias, 0L) > now) + { + return null + } + val cert = runCatching { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(alias) }.getOrNull() ?: return null + val days = TimeUnit.MILLISECONDS.toDays(cert.notAfter.time - now) + return if (days <= CERT_SOON_DAYS) CertExpiry(alias, days, cert.notAfter.time) else null +} + +/** «Later»: the card comes back tomorrow. */ +internal fun snoozeCertExpiry(context: Context, alias: String) +{ + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(SNOOZE_PREFIX + alias, System.currentTimeMillis() + SNOOZE_MS).apply() +} + +/** Amber card of F_CertExpiry: title, what happens when, «Update now» / «Later». */ +@Composable +internal fun CertExpiryCard(expiry: CertExpiry, server: String, onUpdate: () -> Unit, onLater: () -> Unit) +{ + val c = LocalShxColors.current + val shape = RoundedCornerShape(24.dp) + val date = SimpleDateFormat("d MMMM", Locale.getDefault()).format(Date(expiry.notAfter)) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().clip(shape).background(c.warningContainer).border(1.dp, c.warningBorder, shape).padding(16.dp), + ) + { + Text( + if (expiry.days < 0) stringResource(R.string.cert_expired_title) else stringResource(R.string.cert_expiry_title, expiry.days.toInt()), + style = ShxType.cardTitle, color = c.onWarningStrong, + ) + Text( + stringResource(if (expiry.days < 0) R.string.cert_expired_text else R.string.cert_expiry_text, server, date), + style = ShxType.note.copy(fontSize = 13.sp, lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onWarningContainer, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) + { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(2f).height(44.dp).clip(RoundedCornerShape(14.dp)).background(Color(0xFFB45309)) + .clickable(role = Role.Button, onClick = onUpdate).padding(horizontal = 14.dp), + ) + { + Text(stringResource(R.string.cert_update_now), style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = Color.White, maxLines = 1) + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f).height(44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, Color(0xFFC7A27A), RoundedCornerShape(14.dp)) + .clickable(role = Role.Button, onClick = onLater).padding(horizontal = 14.dp), + ) + { + Text(stringResource(R.string.cert_later), style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.onWarningStrong, maxLines = 1) + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt index 55064544a..ceb59465e 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeContent.kt @@ -61,6 +61,7 @@ internal data class HomeModel( val networkKinds: Pair, val unread: Int, val findingFastest: Boolean, + val certExpiry: CertExpiry? = null, ) /** What Home can do. */ @@ -82,6 +83,8 @@ internal class HomeActions( val networkSettings: () -> Unit, val vpnSettings: () -> Unit, val details: () -> Unit, + val certUpdate: () -> Unit = {}, + val certLater: () -> Unit = {}, ) /** Two-letter badge of a server: the exit country when known, else the profile initials. */ @@ -335,6 +338,9 @@ internal fun HomeContent(m: HomeModel, a: HomeActions) } } } + m.certExpiry?.let { expiry -> + CertExpiryCard(expiry, profile?.name ?: profile?.gateway.orEmpty(), a.certUpdate, a.certLater) + } } } } 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 59b58267d..d749dbd3f 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 @@ -1089,4 +1089,10 @@ Здесь сертификатов нет. %1$d из профилей %1$d из профилей · %2$d скоро истекает + Сертификат истекает через %1$d дн. + «%1$s» перестанет подключаться %2$s. Попросите у администратора новый профиль и добавьте его. + Сертификат истёк + «%1$s» не подключается с %2$s. Попросите у администратора новый профиль и добавьте его. + Обновить сейчас + Позже 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 95b4f0c20..f548a4863 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1075,4 +1075,10 @@ No certificates here. %1$d from profiles %1$d from profiles · %2$d expires soon + Certificate expires in %1$d days + «%1$s» stops connecting on %2$s. Ask your admin for a new profile and add it. + Certificate expired + «%1$s» no longer connects since %2$s. Ask your admin for a new profile and add it. + Update now + Later