android: certificate expiry card on Home as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:55:39 +07:00
1 parent e465797b5f
commit ffa38d5f28
5 files changed
+149

No files matched your search

@@ -118,6 +118,12 @@ import kotlinx.coroutines.flow.StateFlow
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.fillMaxHeight 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 org.strongswan.android.R
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.logic.routing.RuIpListManager 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 problem = if (pauseActive && pauseUntil > now) null else homeProblem(state, networkAvailable)
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
val lockdown = remember { context.isVpnLockdownEnabled() } 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<CertExpiry?>(null, certAlias, certSnoozes) {
value = withContext(Dispatchers.IO) { certExpiry(context, certAlias) }
}
val model = HomeModel( val model = HomeModel(
state = state, state = state,
traffic = traffic, traffic = traffic,
@@ -257,6 +269,7 @@ fun HomeScreen(
networkKinds = networkKinds, networkKinds = networkKinds,
unread = unreadEvents, unread = unreadEvents,
findingFastest = findingFastest, findingFastest = findingFastest,
certExpiry = certExpiry,
) )
val actions = HomeActions( val actions = HomeActions(
connect = { selected?.let { viewModel.connect(context, it) } }, connect = { selected?.let { viewModel.connect(context, it) } },
@@ -297,6 +310,11 @@ fun HomeScreen(
} }
}, },
details = { viewModel.openDetails(true) }, details = { viewModel.openDetails(true) },
certUpdate = { ShxNavigation.requestAddServer() },
certLater = {
certAlias?.let { snoozeCertExpiry(context, it) }
certSnoozes++
},
) )
LazyColumn( LazyColumn(
@@ -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)
}
}
}
}
@@ -61,6 +61,7 @@ internal data class HomeModel(
val networkKinds: Pair<Boolean, Boolean>, val networkKinds: Pair<Boolean, Boolean>,
val unread: Int, val unread: Int,
val findingFastest: Boolean, val findingFastest: Boolean,
val certExpiry: CertExpiry? = null,
) )
/** What Home can do. */ /** What Home can do. */
@@ -82,6 +83,8 @@ internal class HomeActions(
val networkSettings: () -> Unit, val networkSettings: () -> Unit,
val vpnSettings: () -> Unit, val vpnSettings: () -> Unit,
val details: () -> 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. */ /** 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)
}
} }
} }
} }
@@ -1089,4 +1089,10 @@
<string name="certs_none">Здесь сертификатов нет.</string> <string name="certs_none">Здесь сертификатов нет.</string>
<string name="settings_certs_profiles">%1$d из профилей</string> <string name="settings_certs_profiles">%1$d из профилей</string>
<string name="settings_certs_soon">%1$d из профилей · %2$d скоро истекает</string> <string name="settings_certs_soon">%1$d из профилей · %2$d скоро истекает</string>
<string name="cert_expiry_title">Сертификат истекает через %1$d дн.</string>
<string name="cert_expiry_text">«%1$s» перестанет подключаться %2$s. Попросите у администратора новый профиль и добавьте его.</string>
<string name="cert_expired_title">Сертификат истёк</string>
<string name="cert_expired_text">«%1$s» не подключается с %2$s. Попросите у администратора новый профиль и добавьте его.</string>
<string name="cert_update_now">Обновить сейчас</string>
<string name="cert_later">Позже</string>
</resources> </resources>
@@ -1075,4 +1075,10 @@
<string name="certs_none">No certificates here.</string> <string name="certs_none">No certificates here.</string>
<string name="settings_certs_profiles">%1$d from profiles</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> <string name="settings_certs_soon">%1$d from profiles · %2$d expires soon</string>
<string name="cert_expiry_title">Certificate expires in %1$d days</string>
<string name="cert_expiry_text">«%1$s» stops connecting on %2$s. Ask your admin for a new profile and add it.</string>
<string name="cert_expired_title">Certificate expired</string>
<string name="cert_expired_text">«%1$s» no longer connects since %2$s. Ask your admin for a new profile and add it.</string>
<string name="cert_update_now">Update now</string>
<string name="cert_later">Later</string>
</resources> </resources>