diff --git a/.github/workflows/macos-package-build.yml b/.github/workflows/macos-package-build.yml
index 9baaf2f..89917b3 100644
--- a/.github/workflows/macos-package-build.yml
+++ b/.github/workflows/macos-package-build.yml
@@ -41,6 +41,20 @@ jobs:
echo "Dmg package: $DMG_FILE"
hdiutil imageinfo "$DMG_FILE" >/dev/null
+
+ case "$GITHUB_REF" in
+ refs/tags/*)
+ VERSION="${GITHUB_REF#refs/tags/}"
+ VERSION="${VERSION#v}"
+ RENAMED="$(dirname "$DMG_FILE")/Stade-$VERSION.dmg"
+ if [ "$DMG_FILE" != "$RENAMED" ]; then
+ mv "$DMG_FILE" "$RENAMED"
+ DMG_FILE="$RENAMED"
+ echo "Renamed to match the release: $DMG_FILE"
+ fi
+ ;;
+ esac
+
echo "DMG_FILE=$DMG_FILE" >> "$GITHUB_ENV"
- name: Upload Dmg package
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 88e2e97..21499ba 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -3,6 +3,23 @@ import java.util.Properties
import java.net.URI
import java.security.MessageDigest
+val appVersion = "0.3.3"
+val appVersionCode = 27
+
+val appVersionSource = file("src/commonMain/kotlin/dev/stade/AppVersion.kt")
+val declaredAppVersion = Regex("""APP_VERSION\s*=\s*"([^"]+)"""")
+ .find(appVersionSource.readText())?.groupValues?.get(1)
+check(declaredAppVersion == appVersion) {
+ "APP_VERSION in ${appVersionSource.name} is $declaredAppVersion but the build declares $appVersion. Update both."
+}
+
+val macAppVersion = appVersion.split(".").let { parts ->
+ val major = (parts.getOrNull(0)?.toIntOrNull() ?: 0) + 1
+ val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
+ val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0
+ "$major.$minor.$patch"
+}
+
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
@@ -67,6 +84,7 @@ kotlin {
dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.biometric)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.sqldelight.android.driver)
implementation("androidx.media3:media3-exoplayer:1.4.1")
@@ -104,8 +122,8 @@ android {
applicationId = "dev.stade"
minSdk = 26
targetSdk = 36
- versionCode = 26
- versionName = "0.3.2"
+ versionCode = appVersionCode
+ versionName = appVersion
}
val localProps = Properties().also { props ->
@@ -186,7 +204,7 @@ compose.desktop {
"java.desktop"
)
packageName = "Stade"
- packageVersion = "0.3.2"
+ packageVersion = appVersion
windows {
iconFile.set(project.file("src/desktopMain/resources/app_icon_desktop.ico"))
menuGroup = "Stade"
@@ -207,7 +225,8 @@ compose.desktop {
macOS {
iconFile.set(project.file("src/desktopMain/resources/app_icon_desktop.icns"))
bundleID = "dev.stade.app"
- packageVersion = "1.2.4"
+ packageVersion = macAppVersion
+ packageBuildVersion = appVersionCode.toString()
}
}
}
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index e92f64f..7bf1805 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -12,6 +12,10 @@
+
+
+
+
()
diff --git a/composeApp/src/androidMain/kotlin/dev/stade/security/BiometricUnlock.android.kt b/composeApp/src/androidMain/kotlin/dev/stade/security/BiometricUnlock.android.kt
new file mode 100644
index 0000000..1a0a27a
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/dev/stade/security/BiometricUnlock.android.kt
@@ -0,0 +1,251 @@
+package dev.stade.security
+
+import android.content.Context
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+import dev.stade.StadeApplication
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+private const val KEY_ALIAS = "stade.biometric.v1"
+private const val KEYSTORE = "AndroidKeyStore"
+private const val TRANSFORM = "AES/GCM/NoPadding"
+private const val GCM_TAG_BITS = 128
+private const val PREFS = "stade_biometric"
+private const val KEY_IV = "iv"
+private const val KEY_CT = "ct"
+
+private const val STRONG = BiometricManager.Authenticators.BIOMETRIC_STRONG
+
+private fun prefs() = StadeApplication.instance.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+private fun keyStore(): KeyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+
+private fun existingKey(): SecretKey? = runCatching {
+ keyStore().getKey(KEY_ALIAS, null) as? SecretKey
+}.getOrNull()
+
+private fun dropKey() {
+ runCatching { keyStore().deleteEntry(KEY_ALIAS) }
+}
+
+private fun generateKey(strongBox: Boolean): SecretKey {
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ val spec = KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .setUserAuthenticationRequired(true)
+ .setInvalidatedByBiometricEnrollment(true)
+ .apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
+ }
+ if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setIsStrongBoxBacked(true)
+ }
+ }
+ .build()
+ generator.init(spec)
+ return generator.generateKey()
+}
+
+private fun freshKey(): SecretKey {
+ dropKey()
+ return runCatching { generateKey(strongBox = true) }.getOrElse { generateKey(strongBox = false) }
+}
+
+private fun storedBlob(): Pair? {
+ val p = prefs()
+ val iv = p.getString(KEY_IV, null) ?: return null
+ val ct = p.getString(KEY_CT, null) ?: return null
+ return runCatching {
+ android.util.Base64.decode(iv, android.util.Base64.NO_WRAP) to
+ android.util.Base64.decode(ct, android.util.Base64.NO_WRAP)
+ }.getOrNull()
+}
+
+private fun storeBlob(iv: ByteArray, ct: ByteArray) {
+ prefs().edit()
+ .putString(KEY_IV, android.util.Base64.encodeToString(iv, android.util.Base64.NO_WRAP))
+ .putString(KEY_CT, android.util.Base64.encodeToString(ct, android.util.Base64.NO_WRAP))
+ .apply()
+}
+
+actual fun biometricAvailability(): BiometricAvailability {
+ val manager = BiometricManager.from(StadeApplication.instance)
+ return when (manager.canAuthenticate(STRONG)) {
+ BiometricManager.BIOMETRIC_SUCCESS -> BiometricAvailability.Ready
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NotEnrolled
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricAvailability.NotEnrolled
+ else -> BiometricAvailability.Unsupported
+ }
+}
+
+actual fun isBiometricUnlockEnrolled(): Boolean = storedBlob() != null
+
+actual fun clearBiometricUnlock() {
+ dropKey()
+ prefs().edit().remove(KEY_IV).remove(KEY_CT).apply()
+}
+
+private class AndroidBiometricGate(private val activity: FragmentActivity?) : BiometricGate {
+
+ override val availability: BiometricAvailability get() = biometricAvailability()
+
+ override val enrolled: Boolean get() = isBiometricUnlockEnrolled()
+
+ private fun prompt(
+ title: String,
+ subtitle: String,
+ negative: String,
+ cipher: Cipher,
+ onError: (Int, CharSequence) -> Unit,
+ onSuccess: (Cipher) -> Unit
+ ) {
+ val host = activity ?: return onError(-1, "")
+ val info = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ .setNegativeButtonText(negative)
+ .setAllowedAuthenticators(STRONG)
+ .setConfirmationRequired(false)
+ .build()
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(code: Int, message: CharSequence) {
+ onError(code, message)
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ val authorized = result.cryptoObject?.cipher
+ if (authorized == null) {
+ onError(-1, "")
+ return
+ }
+ onSuccess(authorized)
+ }
+ }
+ val executor = ContextCompat.getMainExecutor(host)
+ BiometricPrompt(host, executor, callback)
+ .authenticate(info, BiometricPrompt.CryptoObject(cipher))
+ }
+
+ private fun fellBack(code: Int): Boolean =
+ code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
+ code == BiometricPrompt.ERROR_USER_CANCELED ||
+ code == BiometricPrompt.ERROR_CANCELED
+
+ override fun authenticate(
+ title: String,
+ subtitle: String,
+ pinFallbackLabel: String,
+ onOutcome: (BiometricOutcome) -> Unit
+ ) {
+ if (activity == null || availability != BiometricAvailability.Ready) {
+ onOutcome(BiometricOutcome.Unavailable)
+ return
+ }
+ val blob = storedBlob() ?: return onOutcome(BiometricOutcome.Unavailable)
+ val key = existingKey()
+ if (key == null) {
+ clearBiometricUnlock()
+ onOutcome(BiometricOutcome.Reset)
+ return
+ }
+ val cipher = try {
+ Cipher.getInstance(TRANSFORM).apply {
+ init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, blob.first))
+ }
+ } catch (_: KeyPermanentlyInvalidatedException) {
+ clearBiometricUnlock()
+ onOutcome(BiometricOutcome.Reset)
+ return
+ } catch (t: Throwable) {
+ onOutcome(BiometricOutcome.Failed(t.message))
+ return
+ }
+ prompt(
+ title = title,
+ subtitle = subtitle,
+ negative = pinFallbackLabel,
+ cipher = cipher,
+ onError = { code, message ->
+ if (fellBack(code)) onOutcome(BiometricOutcome.FellBackToPin)
+ else onOutcome(BiometricOutcome.Failed(message.toString().ifBlank { null }))
+ },
+ onSuccess = { authorized ->
+ val pin = runCatching { authorized.doFinal(blob.second).decodeToString() }.getOrNull()
+ if (pin.isNullOrEmpty()) {
+ clearBiometricUnlock()
+ onOutcome(BiometricOutcome.Reset)
+ } else {
+ onOutcome(BiometricOutcome.Unlocked(pin))
+ }
+ }
+ )
+ }
+
+ override fun enable(
+ pin: String,
+ title: String,
+ subtitle: String,
+ cancelLabel: String,
+ onOutcome: (BiometricOutcome) -> Unit
+ ) {
+ if (activity == null || availability != BiometricAvailability.Ready) {
+ onOutcome(BiometricOutcome.Unavailable)
+ return
+ }
+ val cipher = try {
+ Cipher.getInstance(TRANSFORM).apply { init(Cipher.ENCRYPT_MODE, freshKey()) }
+ } catch (t: Throwable) {
+ onOutcome(BiometricOutcome.Failed(t.message))
+ return
+ }
+ prompt(
+ title = title,
+ subtitle = subtitle,
+ negative = cancelLabel,
+ cipher = cipher,
+ onError = { code, message ->
+ clearBiometricUnlock()
+ if (fellBack(code)) onOutcome(BiometricOutcome.FellBackToPin)
+ else onOutcome(BiometricOutcome.Failed(message.toString().ifBlank { null }))
+ },
+ onSuccess = { authorized ->
+ val sealed = runCatching { authorized.doFinal(pin.encodeToByteArray()) }.getOrNull()
+ val iv = authorized.iv
+ if (sealed == null || iv == null) {
+ clearBiometricUnlock()
+ onOutcome(BiometricOutcome.Failed(null))
+ } else {
+ storeBlob(iv, sealed)
+ onOutcome(BiometricOutcome.Unlocked(pin))
+ }
+ }
+ )
+ }
+
+ override fun disable() = clearBiometricUnlock()
+}
+
+@Composable
+actual fun rememberBiometricGate(): BiometricGate {
+ val context = LocalContext.current
+ return remember(context) { AndroidBiometricGate(context as? FragmentActivity) }
+}
diff --git a/composeApp/src/androidMain/kotlin/dev/stade/service/StadeService.kt b/composeApp/src/androidMain/kotlin/dev/stade/service/StadeService.kt
index cac4010..316e21c 100644
--- a/composeApp/src/androidMain/kotlin/dev/stade/service/StadeService.kt
+++ b/composeApp/src/androidMain/kotlin/dev/stade/service/StadeService.kt
@@ -28,6 +28,7 @@ import dev.stade.notification.getConversationShortcutsEnabled
import dev.stade.notification.getNotificationPrivacyEnabled
import dev.stade.notification.getNotificationsEnabled
import dev.stade.sync.SyncEngine
+import dev.stade.backgroundFailures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -41,7 +42,7 @@ class StadeService : Service() {
private val msgChannelId = "stade.messages"
private val notificationId = NotificationIds.FOREGROUND
private val hiddenNotifId = NotificationIds.HIDDEN_MESSAGES
- private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob() + backgroundFailures)
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var shutdownReceiver: BroadcastReceiver? = null
diff --git a/composeApp/src/androidMain/kotlin/dev/stade/ui/BackupIo.android.kt b/composeApp/src/androidMain/kotlin/dev/stade/ui/BackupIo.android.kt
index 07fd322..e1faafd 100644
--- a/composeApp/src/androidMain/kotlin/dev/stade/ui/BackupIo.android.kt
+++ b/composeApp/src/androidMain/kotlin/dev/stade/ui/BackupIo.android.kt
@@ -1,5 +1,6 @@
package dev.stade.ui
+import android.provider.DocumentsContract
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
@@ -47,12 +48,19 @@ actual fun rememberBackupIo(
scope.launch {
val ok = withContext(Dispatchers.IO) {
runCatching {
- vault.flushAndKeep()
- context.contentResolver.openOutputStream(uri)?.use { out ->
- StadeBackup.export(out, passphrase.toCharArray(), StadeBackup.targetsFor(vault))
- } ?: false
+ vault.withFlushLock {
+ vault.flushAndKeep()
+ context.contentResolver.openOutputStream(uri)?.use { out ->
+ StadeBackup.export(out, passphrase.toCharArray(), StadeBackup.targetsFor(vault))
+ } ?: false
+ }
}.getOrDefault(false)
}
+ if (!ok) {
+ withContext(Dispatchers.IO) {
+ runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
+ }
+ }
callback(if (ok) BackupOutcome.Exported else BackupOutcome.Failed)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt b/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt
index 8748acb..6ac5bdc 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt
@@ -40,6 +40,8 @@ import dev.stade.transport.ConnectionRegistry
import dev.stade.transport.TransportPlugin
import dev.stade.transport.TransportSettings
import dev.stade.vanish.VanishManager
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -246,14 +248,14 @@ class AppContainer(
val pendingGoHome = MutableStateFlow(false)
- val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + backgroundFailures)
@Volatile var isClosed = false
private set
suspend fun wipeAllData() {
isClosed = true
- runCatching { connections.stop() }
+ quiesce()
pendingInvite.value = null
pendingOpenChat.value = null
pendingGoHome.value = false
@@ -282,16 +284,21 @@ class AppContainer(
}
}
runCatching { driver.close() }
+ runCatching { dev.stade.security.clearBiometricUnlock() }
runCatching { vault.wipe() }
- runCatching { appScope.cancel() }
}
- fun close() {
+ suspend fun close() {
+ if (isClosed) return
isClosed = true
- appScope.launch {
- runCatching { connections.stop() }
- runCatching { appScope.cancel() }
- }
+ quiesce()
runCatching { driver.close() }
}
+
+ private suspend fun quiesce() {
+ runCatching { connections.stop() }
+ val job = appScope.coroutineContext[Job]
+ job?.cancel()
+ runCatching { withTimeoutOrNull(SHUTDOWN_GRACE_MS) { job?.join() } }
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt b/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt
index 75d3b3a..06f5ab3 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt
@@ -1,3 +1,3 @@
package dev.stade
-const val APP_VERSION = "0.3.2"
+const val APP_VERSION = "0.3.3"
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/BackgroundFailures.kt b/composeApp/src/commonMain/kotlin/dev/stade/BackgroundFailures.kt
new file mode 100644
index 0000000..40fa762
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/stade/BackgroundFailures.kt
@@ -0,0 +1,13 @@
+package dev.stade
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineExceptionHandler
+
+const val SHUTDOWN_GRACE_MS = 5_000L
+
+val backgroundFailures = CoroutineExceptionHandler { _, throwable ->
+ if (throwable is CancellationException) return@CoroutineExceptionHandler
+ reportBackgroundFailure(throwable)
+}
+
+expect fun reportBackgroundFailure(throwable: Throwable)
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt b/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt
index 796f588..25a2069 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt
@@ -57,9 +57,19 @@ class BootContext(
fun buildContainer(): AppContainer = synchronized(lock) {
active?.takeIf { !it.isClosed }?.let { return it }
+ val retired = active
val container = AppContainer(driverFactory, vault, transportFactory)
+ if (retired != null) carryPendingIntents(retired, container)
active = container
onContainerCreated(container)
container
}
+
+ private fun carryPendingIntents(from: AppContainer, to: AppContainer) {
+ from.pendingInvite.value?.let { to.pendingInvite.value = it }
+ from.pendingOpenChat.value?.let { to.pendingOpenChat.value = it }
+ from.pendingOpenStadium.value?.let { to.pendingOpenStadium.value = it }
+ from.pendingOpenGroup.value?.let { to.pendingOpenGroup.value = it }
+ if (from.pendingGoHome.value) to.pendingGoHome.value = true
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/security/BiometricUnlock.kt b/composeApp/src/commonMain/kotlin/dev/stade/security/BiometricUnlock.kt
new file mode 100644
index 0000000..4559e26
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/stade/security/BiometricUnlock.kt
@@ -0,0 +1,50 @@
+package dev.stade.security
+
+import androidx.compose.runtime.Composable
+
+enum class BiometricAvailability { Unsupported, NotEnrolled, Ready }
+
+sealed interface BiometricOutcome {
+ data class Unlocked(val pin: String) : BiometricOutcome
+ data object FellBackToPin : BiometricOutcome
+ data object Unavailable : BiometricOutcome
+ data object Reset : BiometricOutcome
+ data class Failed(val message: String?) : BiometricOutcome
+}
+
+interface BiometricGate {
+ val availability: BiometricAvailability
+ val enrolled: Boolean
+
+ fun authenticate(
+ title: String,
+ subtitle: String,
+ pinFallbackLabel: String,
+ onOutcome: (BiometricOutcome) -> Unit
+ )
+
+ fun enable(
+ pin: String,
+ title: String,
+ subtitle: String,
+ cancelLabel: String,
+ onOutcome: (BiometricOutcome) -> Unit
+ )
+
+ fun disable()
+}
+
+expect fun biometricAvailability(): BiometricAvailability
+
+expect fun isBiometricUnlockEnrolled(): Boolean
+
+expect fun clearBiometricUnlock()
+
+@Composable
+expect fun rememberBiometricGate(): BiometricGate
+
+val biometricSupported: Boolean
+ get() = biometricAvailability() != BiometricAvailability.Unsupported
+
+fun biometricUnlockReady(): Boolean =
+ isBiometricUnlockEnrolled() && biometricAvailability() == BiometricAvailability.Ready
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/security/Vault.kt b/composeApp/src/commonMain/kotlin/dev/stade/security/Vault.kt
index 82f31ee..1bfe8e3 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/security/Vault.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/security/Vault.kt
@@ -12,6 +12,7 @@ interface Vault {
fun isDuressPin(candidate: String): Boolean
fun clearDuressPin()
fun flushAndKeep()
+ fun withFlushLock(block: () -> T): T
fun flushAndClose()
fun wipe()
fun metaPath(): String
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/transport/ConnectionManager.kt b/composeApp/src/commonMain/kotlin/dev/stade/transport/ConnectionManager.kt
index 9183cd3..d36382e 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/transport/ConnectionManager.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/transport/ConnectionManager.kt
@@ -5,6 +5,10 @@ import dev.stade.contact.Contact
import dev.stade.identity.LocalIdentity
import dev.stade.sync.SyncEngine
import dev.stade.ui.i18n.I18n
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.withTimeoutOrNull
+import dev.stade.SHUTDOWN_GRACE_MS
+import dev.stade.backgroundFailures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -38,7 +42,7 @@ class ConnectionManager(
private val transportSettings: TransportSettings,
private val isForeground: () -> Boolean = { true }
) {
- private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
private val mutex = Mutex()
private var ownerRef: LocalIdentity? = null
private val tasks = mutableListOf()
@@ -185,7 +189,7 @@ class ConnectionManager(
if (ownerRef?.id == owner.id) return@withLock
if (ownerRef != null) stopInternal()
ownerRef = owner
- if (!scope.isActive) scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ if (!scope.isActive) scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
for (plugin in registry.all()) {
if (!isEnabled(plugin.type)) continue
tasks += scope.launch {
@@ -236,7 +240,7 @@ class ConnectionManager(
val plugin = registry.get(type) ?: return@withLock
runCatching { plugin.stop() }
if (!isEnabled(type)) return@withLock
- if (!scope.isActive) scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ if (!scope.isActive) scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
tasks += scope.launch {
runCatching {
plugin.start { connection -> sync.handleConnection(owner, connection) }
@@ -250,7 +254,7 @@ class ConnectionManager(
if (enabled) {
val owner = ownerRef ?: return@withLock
if (plugin.info.value.running) return@withLock
- if (!scope.isActive) scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ if (!scope.isActive) scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
tasks += scope.launch {
runCatching {
plugin.start { connection -> sync.handleConnection(owner, connection) }
@@ -269,7 +273,10 @@ class ConnectionManager(
private suspend fun stopInternal() {
ownerRef = null
tasks.forEach { it.cancel() }
+ for (plugin in registry.all()) runCatching { plugin.stop() }
+ val running = tasks.toList()
tasks.clear()
+ runCatching { withTimeoutOrNull(SHUTDOWN_GRACE_MS) { running.joinAll() } }
clearAllBackoff()
clearAllFailCounts()
clearDialing()
@@ -278,9 +285,10 @@ class ConnectionManager(
pendingAttempts.clear()
}
_pendingDials.value = emptyMap()
- for (plugin in registry.all()) runCatching { plugin.stop() }
+ val job = scope.coroutineContext[Job]
scope.cancel()
- scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ runCatching { withTimeoutOrNull(SHUTDOWN_GRACE_MS) { job?.join() } }
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
}
private suspend fun dialerLoop(owner: LocalIdentity) {
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt
index de8478f..18ab979 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt
@@ -263,6 +263,7 @@ fun StadeApp(boot: BootContext) {
container = null
boot.markLocked()
unlocked = false
+ runCatching { dev.stade.security.clearBiometricUnlock() }
runCatching { vault.wipe() }
initialized = vault.isInitialized()
autoUnlockTried = true
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt
index ed68207..8bb6306 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt
@@ -328,6 +328,20 @@ abstract class AppStrings {
abstract val duressPinInfoTitle: String
abstract val duressPinInfoBody: String
abstract val clearDuressPinAction: String
+ abstract val biometricUnlockTitle: String
+ abstract val biometricUnlockOnSubtitle: String
+ abstract val biometricUnlockOffSubtitle: String
+ abstract val biometricNotEnrolledSubtitle: String
+ abstract val biometricPromptTitle: String
+ abstract val biometricPromptSubtitle: String
+ abstract val biometricEnablePromptSubtitle: String
+ abstract val biometricUsePinAction: String
+ abstract val biometricUseFingerprintAction: String
+ abstract val biometricConfirmPinTitle: String
+ abstract val biometricConfirmPinBody: String
+ abstract val biometricEnabledNotice: String
+ abstract val biometricFailedNotice: String
+ abstract val biometricResetNotice: String
abstract val transportsTitle: String
abstract val notRegistered: String
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt
index 0f1f459..b1cfc8d 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt
@@ -363,6 +363,20 @@ object ArabicStrings : AppStrings() {
override val duressPinInfoBody =
"أدخل هذا الرمز في شاشة القفل بدلًا من رمزك الحقيقي، فيمحو Stade فورًا كل شيء على هذا الجهاز — هويتك وجهات اتصالك ورسائلك ومجموعاتك واستادياتك. عندها يبدو التطبيق تمامًا كتثبيت جديد، دون أي أثر يدل على استخدام رمز إكراه. لا تحذير ولا تأكيد، ولا شيء يمكن استرجاعه بعدها. ولا يُبلَّغ جهات اتصالك."
override val clearDuressPinAction = "إزالة رمز الإكراه"
+ override val biometricUnlockTitle = "الفتح ببصمة الإصبع"
+ override val biometricUnlockOnSubtitle = "يمكن لبصمتك فتح Stade بدلًا من رمز PIN"
+ override val biometricUnlockOffSubtitle = "يلزم رمز PIN في كل مرة"
+ override val biometricNotEnrolledSubtitle = "أضف بصمة إصبع من إعدادات جهازك أولًا"
+ override val biometricPromptTitle = "فتح قفل Stade"
+ override val biometricPromptSubtitle = "أكّد بصمتك للمتابعة"
+ override val biometricEnablePromptSubtitle = "أكّد بصمتك لتفعيل هذا الخيار"
+ override val biometricUsePinAction = "المتابعة برمز PIN"
+ override val biometricUseFingerprintAction = "استخدام البصمة"
+ override val biometricConfirmPinTitle = "أكّد رمز PIN"
+ override val biometricConfirmPinBody = "أدخل رمز PIN مرة واحدة. يُختم بمفتاح لا تُطلقه إلا بصمتك، ولا يغادر هذا الجهاز أبدًا."
+ override val biometricEnabledNotice = "الفتح ببصمة الإصبع مُفعَّل"
+ override val biometricFailedNotice = "تعذّر التحقق من البصمة. استخدم رمز PIN."
+ override val biometricResetNotice = "أُوقف الفتح بالبصمة لأن بصمات هذا الجهاز تغيّرت. أدخل رمز PIN."
override val autoLockNeverInfoTitle = "عن خيار «أبدًا»"
override val autoLockNeverInfoBody =
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt
index d0f0e6d..19f33bb 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt
@@ -375,6 +375,20 @@ object EnglishStrings : AppStrings() {
"There is no warning, no confirmation, and nothing can be recovered afterwards. " +
"Your contacts are not notified."
override val clearDuressPinAction = "Remove duress PIN"
+ override val biometricUnlockTitle = "Unlock with fingerprint"
+ override val biometricUnlockOnSubtitle = "Your fingerprint can unlock Stade instead of your PIN"
+ override val biometricUnlockOffSubtitle = "Your PIN is required every time"
+ override val biometricNotEnrolledSubtitle = "Add a fingerprint in your device settings first"
+ override val biometricPromptTitle = "Unlock Stade"
+ override val biometricPromptSubtitle = "Confirm your fingerprint to continue"
+ override val biometricEnablePromptSubtitle = "Confirm your fingerprint to turn this on"
+ override val biometricUsePinAction = "Continue with PIN"
+ override val biometricUseFingerprintAction = "Use fingerprint"
+ override val biometricConfirmPinTitle = "Confirm your PIN"
+ override val biometricConfirmPinBody = "Enter your PIN once. It is sealed with a key your fingerprint releases, and never leaves this device."
+ override val biometricEnabledNotice = "Fingerprint unlock is on"
+ override val biometricFailedNotice = "Fingerprint could not be verified. Use your PIN."
+ override val biometricResetNotice = "Fingerprint unlock was turned off because the fingerprints on this device changed. Enter your PIN."
override val autoLockNeverInfoTitle = "About the 'Never' Option"
override val autoLockNeverInfoBody =
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt
index 6012bda..c97fe4f 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt
@@ -363,6 +363,20 @@ object KoreanStrings : AppStrings() {
override val duressPinInfoBody =
"잠금 화면에서 실제 PIN 대신 이 PIN을 입력하면 Stade가 이 기기의 모든 것을 즉시 지웁니다 — 신원, 연락처, 메시지, 그룹, Stadium까지 전부입니다. 그 후 앱은 새로 설치한 것과 똑같아 보이며, 비상 PIN이 사용되었다는 흔적도 남지 않습니다. 경고도 확인 절차도 없고, 이후에는 아무것도 복구할 수 없습니다. 연락처에도 알림이 가지 않습니다."
override val clearDuressPinAction = "비상 PIN 삭제"
+ override val biometricUnlockTitle = "지문으로 잠금 해제"
+ override val biometricUnlockOnSubtitle = "PIN 대신 지문으로 Stade 잠금을 해제할 수 있습니다"
+ override val biometricUnlockOffSubtitle = "매번 PIN이 필요합니다"
+ override val biometricNotEnrolledSubtitle = "먼저 기기 설정에서 지문을 등록하세요"
+ override val biometricPromptTitle = "Stade 잠금 해제"
+ override val biometricPromptSubtitle = "계속하려면 지문을 확인하세요"
+ override val biometricEnablePromptSubtitle = "이 기능을 켜려면 지문을 확인하세요"
+ override val biometricUsePinAction = "PIN으로 계속"
+ override val biometricUseFingerprintAction = "지문 사용"
+ override val biometricConfirmPinTitle = "PIN 확인"
+ override val biometricConfirmPinBody = "PIN을 한 번 입력하세요. 지문으로만 열리는 키로 봉인되며 이 기기를 벗어나지 않습니다."
+ override val biometricEnabledNotice = "지문 잠금 해제가 켜졌습니다"
+ override val biometricFailedNotice = "지문을 확인할 수 없습니다. PIN을 사용하세요."
+ override val biometricResetNotice = "이 기기의 지문이 변경되어 지문 잠금 해제가 꺼졌습니다. PIN을 입력하세요."
override val autoLockNeverInfoTitle = "'안 함' 옵션에 대하여"
override val autoLockNeverInfoBody =
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt
index e292f60..52dfa93 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt
@@ -363,6 +363,20 @@ object PersianStrings : AppStrings() {
override val duressPinInfoBody =
"این پین را بهجای پین واقعی در صفحهٔ قفل وارد کنید و Stade بلافاصله همهچیز را روی این دستگاه پاک میکند — هویت، مخاطبان، پیامها، گروهها و استادیومهای شما. سپس برنامه دقیقاً مثل یک نصب تازه به نظر میرسد، بدون هیچ نشانهای از استفاده از پین اضطراری. هیچ هشدار و تأییدی در کار نیست و بعد از آن هیچچیز قابل بازیابی نیست. به مخاطبان شما اطلاع داده نمیشود."
override val clearDuressPinAction = "حذف پین اضطراری"
+ override val biometricUnlockTitle = "باز کردن با اثر انگشت"
+ override val biometricUnlockOnSubtitle = "اثر انگشت شما میتواند بهجای پین، قفل Stade را باز کند"
+ override val biometricUnlockOffSubtitle = "هر بار پین لازم است"
+ override val biometricNotEnrolledSubtitle = "نخست از تنظیمات دستگاه خود یک اثر انگشت اضافه کنید"
+ override val biometricPromptTitle = "باز کردن قفل Stade"
+ override val biometricPromptSubtitle = "برای ادامه اثر انگشت خود را تأیید کنید"
+ override val biometricEnablePromptSubtitle = "برای روشن کردن این گزینه اثر انگشت خود را تأیید کنید"
+ override val biometricUsePinAction = "ادامه با پین"
+ override val biometricUseFingerprintAction = "استفاده از اثر انگشت"
+ override val biometricConfirmPinTitle = "پین خود را تأیید کنید"
+ override val biometricConfirmPinBody = "پین خود را یک بار وارد کنید. با کلیدی مهر و موم میشود که تنها اثر انگشت شما آن را آزاد میکند و هرگز از این دستگاه بیرون نمیرود."
+ override val biometricEnabledNotice = "باز کردن با اثر انگشت روشن است"
+ override val biometricFailedNotice = "اثر انگشت تأیید نشد. از پین خود استفاده کنید."
+ override val biometricResetNotice = "چون اثر انگشتهای این دستگاه تغییر کرده، باز کردن با اثر انگشت خاموش شد. پین خود را وارد کنید."
override val autoLockNeverInfoTitle = "دربارهٔ گزینهٔ «هرگز»"
override val autoLockNeverInfoBody =
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt
index 9a9c2b4..363550c 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt
@@ -376,6 +376,20 @@ object TurkishStrings : AppStrings() {
"Uyarı ya da onay yoktur ve sonrasında hiçbir şey geri getirilemez. " +
"Kişilerine bildirim gitmez."
override val clearDuressPinAction = "Tehlike PIN'ini kaldır"
+ override val biometricUnlockTitle = "Parmak iziyle aç"
+ override val biometricUnlockOnSubtitle = "Parmak iziniz, PIN yerine Stade'in kilidini açabilir"
+ override val biometricUnlockOffSubtitle = "Her seferinde PIN gerekir"
+ override val biometricNotEnrolledSubtitle = "Önce cihaz ayarlarınızdan bir parmak izi ekleyin"
+ override val biometricPromptTitle = "Stade'in kilidini aç"
+ override val biometricPromptSubtitle = "Devam etmek için parmak izinizi doğrulayın"
+ override val biometricEnablePromptSubtitle = "Bunu açmak için parmak izinizi doğrulayın"
+ override val biometricUsePinAction = "PIN ile devam et"
+ override val biometricUseFingerprintAction = "Parmak izini kullan"
+ override val biometricConfirmPinTitle = "PIN'inizi doğrulayın"
+ override val biometricConfirmPinBody = "PIN'inizi bir kez girin. Parmak izinizin açtığı bir anahtarla mühürlenir ve bu cihazdan hiç çıkmaz."
+ override val biometricEnabledNotice = "Parmak iziyle açma etkin"
+ override val biometricFailedNotice = "Parmak izi doğrulanamadı. PIN'inizi kullanın."
+ override val biometricResetNotice = "Bu cihazdaki parmak izleri değiştiği için parmak iziyle açma kapatıldı. PIN'inizi girin."
override val autoLockNeverInfoTitle = "«Asla» Seçeneği Hakkında"
override val autoLockNeverInfoBody =
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/LockScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/LockScreen.kt
index 4ded105..5baa632 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/LockScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/LockScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -53,6 +54,10 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import dev.stade.security.BiometricAvailability
+import dev.stade.security.BiometricOutcome
+import dev.stade.security.clearBiometricUnlock
+import dev.stade.security.rememberBiometricGate
import dev.stade.security.UnlockOutcome
import dev.stade.security.Vault
import dev.stade.ui.components.BrandMark
@@ -100,6 +105,10 @@ fun LockScreen(
val scrambleEnabled = remember { vault.isScrambleKeypadEnabled() }
val strings = LocalStrings.current
val keyFocusRequester = remember { FocusRequester() }
+ val biometrics = rememberBiometricGate()
+ var biometricPrompting by remember { mutableStateOf(false) }
+ var biometricOffered by remember { mutableStateOf(false) }
+ val biometricUsable = biometrics.enrolled && biometrics.availability == BiometricAvailability.Ready
LaunchedEffect(Unit) {
delay(80)
@@ -116,6 +125,44 @@ fun LockScreen(
val lockedNow = lockoutUntil > nowTick
+ fun unlockWith(candidate: String, onWrong: () -> Unit) {
+ isVerifying = true
+ scope.launch {
+ val outcome = withContext(Dispatchers.Default) { vault.unlock(candidate) }
+ isVerifying = false
+ if (outcome is UnlockOutcome.Success) onUnlocked() else onWrong()
+ }
+ }
+
+ fun promptBiometric() {
+ if (biometricPrompting || isVerifying || lockedNow || !biometricUsable) return
+ biometricPrompting = true
+ biometrics.authenticate(
+ title = strings.biometricPromptTitle,
+ subtitle = strings.biometricPromptSubtitle,
+ pinFallbackLabel = strings.biometricUsePinAction
+ ) { outcome ->
+ biometricPrompting = false
+ when (outcome) {
+ is BiometricOutcome.Unlocked -> unlockWith(outcome.pin) {
+ clearBiometricUnlock()
+ error = strings.biometricResetNotice
+ }
+ BiometricOutcome.Reset -> error = strings.biometricResetNotice
+ is BiometricOutcome.Failed -> error = outcome.message ?: strings.biometricFailedNotice
+ else -> Unit
+ }
+ }
+ }
+
+ LaunchedEffect(biometricUsable) {
+ if (biometricUsable && !biometricOffered && !lockedNow) {
+ biometricOffered = true
+ delay(220)
+ promptBiometric()
+ }
+ }
+
fun tryUnlock() {
if (pin.length < PIN_MIN || error != null || isVerifying || lockedNow) return
val currentPin = pin
@@ -280,6 +327,20 @@ fun LockScreen(
)
}
Spacer(Modifier.height(16.dp))
+ if (biometricUsable) {
+ TextButton(
+ onClick = { promptBiometric() },
+ enabled = !wiping && !lockedNow && !isVerifying && !biometricPrompting
+ ) {
+ Icon(
+ Icons.Filled.Fingerprint,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(strings.biometricUseFingerprintAction)
+ }
+ }
TextButton(onClick = { showForgotDialog = true }, enabled = !wiping) {
Text(strings.forgotPin)
}
@@ -311,6 +372,7 @@ fun LockScreen(
scope.launch {
withContext(Dispatchers.Default) {
runCatching { onPrepareWipe?.invoke() }
+ runCatching { clearBiometricUnlock() }
vault.wipe()
}
showForgotDialog = false
@@ -413,6 +475,7 @@ fun PinSetupScreen(
}
isVerifying = false
if (ok) {
+ if (mode != PinSetupMode.Duress) clearBiometricUnlock()
onDone()
} else {
error = strings.pinChangeFailed
diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/SecuritySettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/SecuritySettingsScreen.kt
index 5462302..1063cc8 100644
--- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/SecuritySettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/SecuritySettingsScreen.kt
@@ -45,6 +45,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
+import androidx.compose.material3.Button
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -72,8 +73,14 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.stade.AppContainer
+import dev.stade.security.BiometricAvailability
+import dev.stade.security.BiometricOutcome
+import dev.stade.security.clearBiometricUnlock
+import dev.stade.security.rememberBiometricGate
+import dev.stade.security.BiometricGate
import dev.stade.security.SessionTimeout
import dev.stade.security.getLockOnShutdownEnabled
import dev.stade.security.isLockOnShutdownSupported
@@ -132,6 +139,11 @@ private fun SecuritySettingsContent(
val linkPreviewsEnabled = remember(refreshTick) { dev.stade.link.getLinkPreviewsEnabled(container.db) }
var timeoutMenuOpen by remember { mutableStateOf(false) }
var showNeverInfoDialog by remember { mutableStateOf(false) }
+ val biometrics = rememberBiometricGate()
+ val biometricAvail = biometrics.availability
+ var biometricOn by remember(refreshTick) { mutableStateOf(biometrics.enrolled) }
+ var showBiometricPinDialog by remember { mutableStateOf(false) }
+ var biometricNotice by remember { mutableStateOf(null) }
var showDuressInfoDialog by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
@@ -168,6 +180,30 @@ private fun SecuritySettingsContent(
subtitle = strings.changePinSubtitle,
onClick = { onOpenPinSetup(true) }
)
+ if (biometricAvail != BiometricAvailability.Unsupported) {
+ SecurityDivider()
+ val ready = biometricAvail == BiometricAvailability.Ready
+ SecuritySwitchRow(
+ icon = Icons.Default.Fingerprint,
+ tint = MaterialTheme.colorScheme.primary,
+ title = strings.biometricUnlockTitle,
+ subtitle = when {
+ !ready -> strings.biometricNotEnrolledSubtitle
+ biometricOn -> strings.biometricUnlockOnSubtitle
+ else -> strings.biometricUnlockOffSubtitle
+ },
+ checked = biometricOn && ready,
+ enabled = ready,
+ onCheckedChange = { want ->
+ if (want) {
+ showBiometricPinDialog = true
+ } else {
+ biometrics.disable()
+ biometricOn = false
+ }
+ }
+ )
+ }
if (isKeypadSupported) {
SecurityDivider()
SecuritySwitchRow(
@@ -356,6 +392,46 @@ private fun SecuritySettingsContent(
}
)
}
+ if (showBiometricPinDialog) {
+ BiometricEnrollDialog(
+ container = container,
+ gate = biometrics,
+ onDismiss = { showBiometricPinDialog = false },
+ onEnabled = {
+ showBiometricPinDialog = false
+ biometricOn = true
+ biometricNotice = strings.biometricEnabledNotice
+ },
+ onFailed = { message ->
+ showBiometricPinDialog = false
+ biometricOn = false
+ biometricNotice = message
+ }
+ )
+ }
+ biometricNotice?.let { notice ->
+ LaunchedEffect(notice) {
+ delay(2600)
+ biometricNotice = null
+ }
+ Box(
+ modifier = Modifier.fillMaxSize().padding(24.dp),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.inverseSurface,
+ shape = RoundedCornerShape(12.dp),
+ tonalElevation = 6.dp
+ ) {
+ Text(
+ notice,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.inverseOnSurface
+ )
+ }
+ }
+ }
if (showNeverInfoDialog) {
AlertDialog(
onDismissRequest = { showNeverInfoDialog = false },
@@ -473,13 +549,14 @@ private fun SecuritySwitchRow(
subtitle: String?,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(modifier)
- .clickable { onCheckedChange(!checked) }
+ .clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -493,7 +570,7 @@ private fun SecuritySwitchRow(
}
}
Spacer(Modifier.width(8.dp))
- Switch(checked = checked, onCheckedChange = onCheckedChange)
+ Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
}
}
@@ -523,12 +600,56 @@ fun SecurityPinGate(
var isVerifying by remember { mutableStateOf(false) }
val shakeOffset = remember { androidx.compose.animation.core.Animatable(0f) }
val keyFocusRequester = remember { FocusRequester() }
+ val biometrics = rememberBiometricGate()
+ var biometricPrompting by remember { mutableStateOf(false) }
+ var biometricOffered by remember { mutableStateOf(false) }
+ val biometricUsable = biometrics.enrolled && biometrics.availability == BiometricAvailability.Ready
LaunchedEffect(Unit) {
delay(80)
runCatching { keyFocusRequester.requestFocus() }
}
+ fun promptBiometric() {
+ if (biometricPrompting || isVerifying || !biometricUsable) return
+ biometricPrompting = true
+ biometrics.authenticate(
+ title = strings.biometricPromptTitle,
+ subtitle = strings.biometricPromptSubtitle,
+ pinFallbackLabel = strings.biometricUsePinAction
+ ) { outcome ->
+ biometricPrompting = false
+ when (outcome) {
+ is BiometricOutcome.Unlocked -> {
+ isVerifying = true
+ scope.launch {
+ val ok = withContext(Dispatchers.Default) {
+ container.secrets.verifyPin(outcome.pin)
+ }
+ isVerifying = false
+ if (ok) {
+ onVerified()
+ } else {
+ clearBiometricUnlock()
+ error = strings.biometricResetNotice
+ }
+ }
+ }
+ BiometricOutcome.Reset -> error = strings.biometricResetNotice
+ is BiometricOutcome.Failed -> error = outcome.message ?: strings.biometricFailedNotice
+ else -> Unit
+ }
+ }
+ }
+
+ LaunchedEffect(biometricUsable) {
+ if (biometricUsable && !biometricOffered) {
+ biometricOffered = true
+ delay(220)
+ promptBiometric()
+ }
+ }
+
fun tryVerify() {
if (pin.length < 4 || isVerifying || error != null) return
val snap = pin
@@ -734,14 +855,33 @@ fun SecurityPinGate(
}
}
}
+ if (biometricUsable) {
+ Spacer(Modifier.height(16.dp))
+ TextButton(
+ onClick = { promptBiometric() },
+ enabled = !isVerifying && !biometricPrompting
+ ) {
+ Icon(
+ Icons.Default.Fingerprint,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(strings.biometricUseFingerprintAction)
+ }
+ }
}
}
}
@Composable
-private fun GatePadButton(onClick: () -> Unit, content: @Composable () -> Unit) {
+private fun GatePadButton(
+ onClick: () -> Unit,
+ size: Dp = 68.dp,
+ content: @Composable () -> Unit
+) {
Surface(
- modifier = Modifier.size(68.dp).clip(CircleShape).clickable(onClick = onClick),
+ modifier = Modifier.size(size).clip(CircleShape).clickable(onClick = onClick),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = CircleShape
) {
@@ -751,3 +891,129 @@ private fun GatePadButton(onClick: () -> Unit, content: @Composable () -> Unit)
}
}
+@Composable
+private fun BiometricEnrollDialog(
+ container: AppContainer,
+ gate: BiometricGate,
+ onDismiss: () -> Unit,
+ onEnabled: () -> Unit,
+ onFailed: (String?) -> Unit
+) {
+ val strings = LocalStrings.current
+ val scope = rememberCoroutineScope()
+ var pin by remember { mutableStateOf("") }
+ var busy by remember { mutableStateOf(false) }
+ var wrong by remember { mutableStateOf(false) }
+
+ fun submit() {
+ if (busy || pin.length < 4) return
+ busy = true
+ wrong = false
+ val candidate = pin
+ scope.launch {
+ val ok = withContext(Dispatchers.Default) { container.secrets.verifyPin(candidate) }
+ if (!ok) {
+ busy = false
+ wrong = true
+ return@launch
+ }
+ gate.enable(
+ pin = candidate,
+ title = strings.biometricUnlockTitle,
+ subtitle = strings.biometricEnablePromptSubtitle,
+ cancelLabel = strings.cancel
+ ) { outcome ->
+ busy = false
+ when (outcome) {
+ is BiometricOutcome.Unlocked -> onEnabled()
+ BiometricOutcome.FellBackToPin -> onDismiss()
+ is BiometricOutcome.Failed -> onFailed(outcome.message ?: strings.biometricFailedNotice)
+ else -> onFailed(strings.biometricFailedNotice)
+ }
+ }
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = { if (!busy) onDismiss() },
+ icon = {
+ Icon(
+ Icons.Default.Fingerprint,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ title = { Text(strings.biometricConfirmPinTitle) },
+ text = {
+ Column {
+ Text(strings.biometricConfirmPinBody, style = MaterialTheme.typography.bodyMedium)
+ Spacer(Modifier.height(16.dp))
+ Box(
+ modifier = Modifier.fillMaxWidth().height(34.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ PinDots(filled = pin.length, shakeOffset = 0f, error = wrong)
+ }
+ if (wrong) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ strings.wrongCurrentPin,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ Spacer(Modifier.height(14.dp))
+ val haptic = LocalHapticFeedback.current
+ fun press(digit: String) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ if (!busy && pin.length < 16) {
+ pin += digit
+ wrong = false
+ }
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9")).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ row.forEach { d ->
+ GatePadButton(onClick = { press(d) }, size = 56.dp) {
+ Text(d, style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Spacer(Modifier.size(56.dp))
+ GatePadButton(onClick = { press("0") }, size = 56.dp) {
+ Text("0", style = MaterialTheme.typography.titleMedium)
+ }
+ GatePadButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ if (pin.isNotEmpty() && !busy) pin = pin.dropLast(1)
+ },
+ size = 56.dp
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.Backspace,
+ contentDescription = strings.backspaceAction,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ Button(onClick = { submit() }, enabled = !busy && pin.length >= 4) {
+ Text(strings.continueAction)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss, enabled = !busy) { Text(strings.cancel) }
+ }
+ )
+}
diff --git a/composeApp/src/desktopMain/kotlin/dev/stade/BackgroundFailures.desktop.kt b/composeApp/src/desktopMain/kotlin/dev/stade/BackgroundFailures.desktop.kt
new file mode 100644
index 0000000..c7c4f5e
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/dev/stade/BackgroundFailures.desktop.kt
@@ -0,0 +1,5 @@
+package dev.stade
+
+actual fun reportBackgroundFailure(throwable: Throwable) {
+ System.err.println("Stade: background task failed: ${throwable.stackTraceToString()}")
+}
diff --git a/composeApp/src/desktopMain/kotlin/dev/stade/security/BiometricUnlock.desktop.kt b/composeApp/src/desktopMain/kotlin/dev/stade/security/BiometricUnlock.desktop.kt
new file mode 100644
index 0000000..1bdb7a1
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/dev/stade/security/BiometricUnlock.desktop.kt
@@ -0,0 +1,35 @@
+package dev.stade.security
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+actual fun biometricAvailability(): BiometricAvailability = BiometricAvailability.Unsupported
+
+actual fun isBiometricUnlockEnrolled(): Boolean = false
+
+actual fun clearBiometricUnlock() {}
+
+private object DesktopBiometricGate : BiometricGate {
+ override val availability = BiometricAvailability.Unsupported
+ override val enrolled = false
+
+ override fun authenticate(
+ title: String,
+ subtitle: String,
+ pinFallbackLabel: String,
+ onOutcome: (BiometricOutcome) -> Unit
+ ) = onOutcome(BiometricOutcome.Unavailable)
+
+ override fun enable(
+ pin: String,
+ title: String,
+ subtitle: String,
+ cancelLabel: String,
+ onOutcome: (BiometricOutcome) -> Unit
+ ) = onOutcome(BiometricOutcome.Unavailable)
+
+ override fun disable() {}
+}
+
+@Composable
+actual fun rememberBiometricGate(): BiometricGate = remember { DesktopBiometricGate }
diff --git a/composeApp/src/desktopMain/kotlin/dev/stade/ui/BackupIo.desktop.kt b/composeApp/src/desktopMain/kotlin/dev/stade/ui/BackupIo.desktop.kt
index cab6593..1ae42a0 100644
--- a/composeApp/src/desktopMain/kotlin/dev/stade/ui/BackupIo.desktop.kt
+++ b/composeApp/src/desktopMain/kotlin/dev/stade/ui/BackupIo.desktop.kt
@@ -49,16 +49,23 @@ actual fun rememberBackupIo(
val target = chooseFile(save = true, suggested = defaultBackupFileName())
?: return@withContext BackupOutcome.Cancelled
runCatching {
- vault.flushAndKeep()
- target.outputStream().use { out ->
- StadeBackup.export(
- out,
- passphrase.toCharArray(),
- StadeBackup.targetsFor(vault)
- )
+ vault.withFlushLock {
+ vault.flushAndKeep()
+ target.outputStream().use { out ->
+ StadeBackup.export(
+ out,
+ passphrase.toCharArray(),
+ StadeBackup.targetsFor(vault)
+ )
+ }
}
}.getOrDefault(false).let {
- if (it) BackupOutcome.Exported else BackupOutcome.Failed
+ if (it) {
+ BackupOutcome.Exported
+ } else {
+ runCatching { target.delete() }
+ BackupOutcome.Failed
+ }
}
}
callback(outcome)
diff --git a/composeApp/src/desktopTest/kotlin/dev/stade/BootContextPendingIntentTest.kt b/composeApp/src/desktopTest/kotlin/dev/stade/BootContextPendingIntentTest.kt
new file mode 100644
index 0000000..8a92a12
--- /dev/null
+++ b/composeApp/src/desktopTest/kotlin/dev/stade/BootContextPendingIntentTest.kt
@@ -0,0 +1,129 @@
+package dev.stade
+
+import dev.stade.db.DriverFactory
+import dev.stade.security.FileVault
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import kotlinx.coroutines.runBlocking
+import java.io.File
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotSame
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+private fun bootOnFreshVault(): BootContext {
+ val root = File(
+ System.getProperty("java.io.tmpdir"),
+ "stade-boot-${Random.nextLong()}"
+ ).apply { mkdirs() }
+ val vault = FileVault(root)
+ vault.setup("123456")
+ return BootContext(vault, DriverFactory(), { emptyList() })
+}
+
+class BootContextPendingIntentTest {
+
+ @Test
+ fun anInviteSurvivesTheSessionLockingAndUnlocking() {
+ val boot = bootOnFreshVault()
+ val locked = boot.buildContainer()
+ locked.pendingInvite.value = "STADE2-EXAMPLE-INVITE"
+ runBlocking { locked.close() }
+
+ val reopened = boot.buildContainer()
+ assertNotSame(locked, reopened)
+ assertEquals("STADE2-EXAMPLE-INVITE", reopened.pendingInvite.value)
+ runBlocking { reopened.close() }
+ }
+
+ @Test
+ fun aTappedNotificationSurvivesTheSessionLockingAndUnlocking() {
+ val boot = bootOnFreshVault()
+ val locked = boot.buildContainer()
+ locked.pendingOpenChat.value = "contact-1"
+ locked.pendingOpenStadium.value = "stadium-1"
+ locked.pendingOpenGroup.value = "group-1"
+ locked.pendingGoHome.value = true
+ runBlocking { locked.close() }
+
+ val reopened = boot.buildContainer()
+ assertEquals("contact-1", reopened.pendingOpenChat.value)
+ assertEquals("stadium-1", reopened.pendingOpenStadium.value)
+ assertEquals("group-1", reopened.pendingOpenGroup.value)
+ assertTrue(reopened.pendingGoHome.value)
+ runBlocking { reopened.close() }
+ }
+
+ @Test
+ fun nothingIsCarriedWhenThereWasNothingPending() {
+ val boot = bootOnFreshVault()
+ val first = boot.buildContainer()
+ runBlocking { first.close() }
+
+ val second = boot.buildContainer()
+ assertNull(second.pendingInvite.value)
+ assertNull(second.pendingOpenChat.value)
+ assertFalse(second.pendingGoHome.value)
+ runBlocking { second.close() }
+ }
+
+ @Test
+ fun aLiveContainerIsReusedRatherThanRebuilt() {
+ val boot = bootOnFreshVault()
+ val first = boot.buildContainer()
+ first.pendingInvite.value = "STADE2-STILL-OPEN"
+ val second = boot.buildContainer()
+ assertSame(first, second)
+ assertEquals("STADE2-STILL-OPEN", second.pendingInvite.value)
+ runBlocking { first.close() }
+ }
+
+ @Test
+ fun closingMarksTheContainerClosedAndIsSafeToRepeat() {
+ val boot = bootOnFreshVault()
+ val container = boot.buildContainer()
+ assertFalse(container.isClosed)
+ runBlocking {
+ container.close()
+ container.close()
+ }
+ assertTrue(container.isClosed)
+ assertNull(boot.activeContainer())
+ }
+
+ @Test
+ fun closingWaitsForBackgroundQueriesInsteadOfCrashingThem() {
+ val boot = bootOnFreshVault()
+ val container = boot.buildContainer()
+ val crash = java.util.concurrent.atomic.AtomicReference(null)
+ val rounds = java.util.concurrent.atomic.AtomicInteger(0)
+
+ val probe = container.appScope.launch {
+ try {
+ while (isActive) {
+ container.db.stadeDbQueries.getKv("probe").executeAsOneOrNull()
+ rounds.incrementAndGet()
+ yield()
+ }
+ } catch (cancelled: kotlinx.coroutines.CancellationException) {
+ throw cancelled
+ } catch (t: Throwable) {
+ crash.set(t)
+ }
+ }
+
+ runBlocking {
+ while (rounds.get() < 5) delay(10)
+ container.close()
+ }
+
+ assertTrue(probe.isCompleted, "background work must be finished once close returns")
+ assertNull(crash.get(), "a background query saw the database after it was closed: ${crash.get()}")
+ }
+}
diff --git a/composeApp/src/desktopTest/kotlin/dev/stade/backup/BackupRealisticSizeTest.kt b/composeApp/src/desktopTest/kotlin/dev/stade/backup/BackupRealisticSizeTest.kt
new file mode 100644
index 0000000..267d6a1
--- /dev/null
+++ b/composeApp/src/desktopTest/kotlin/dev/stade/backup/BackupRealisticSizeTest.kt
@@ -0,0 +1,164 @@
+package dev.stade.backup
+
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.assertContentEquals
+
+private class Install(val root: File) {
+ val targets = BackupTargets(
+ vaultMetaPath = File(root, "stade.vault").absolutePath,
+ encryptedDbPath = File(root, "stade.db.enc").absolutePath,
+ plaintextDbPath = File(root, "stade.db").absolutePath,
+ onionKeyPath = File(root, "tor/hs/hs_ed25519_secret_key").absolutePath
+ )
+
+ fun seed(dbBytes: ByteArray) {
+ root.mkdirs()
+ File(targets.vaultMetaPath).writeBytes(Random(1).nextBytes(196))
+ File(targets.encryptedDbPath).writeBytes(dbBytes)
+ File(targets.onionKeyPath).apply { parentFile.mkdirs() }.writeBytes(Random(2).nextBytes(96))
+ }
+}
+
+private fun tempRoot(name: String): File =
+ File(System.getProperty("java.io.tmpdir"), "stade-$name-${Random.nextLong()}").apply { mkdirs() }
+
+class BackupRealisticSizeTest {
+
+ @Test
+ fun aMultiMegabyteAccountSurvivesExportAndRestore() {
+ val source = Install(tempRoot("src"))
+ val db = Random(7).nextBytes(8 * 1024 * 1024 + 12_345)
+ source.seed(db)
+
+ val out = ByteArrayOutputStream()
+ assertTrue(StadeBackup.export(out, "a-long-enough-passphrase".toCharArray(), source.targets))
+
+ val target = Install(tempRoot("dst"))
+ target.root.mkdirs()
+ val scratch = File(tempRoot("scratch"), "payload.tmp")
+ val outcome = StadeBackup.restore(
+ ByteArrayInputStream(out.toByteArray()),
+ "a-long-enough-passphrase".toCharArray(),
+ target.targets,
+ scratch
+ )
+ assertTrue(outcome is RestoreOutcome.Restored, "expected a restore, got $outcome")
+ assertContentEquals(db, File(target.targets.encryptedDbPath).readBytes())
+ }
+
+ @Test
+ fun aDatabaseThatGrowsWhileTheBackupIsWrittenStillRestores() {
+ val source = Install(tempRoot("grow"))
+ val db = Random(8).nextBytes(3 * 1024 * 1024)
+ source.seed(db)
+
+ val meta = File(source.targets.vaultMetaPath)
+ val encrypted = File(source.targets.encryptedDbPath)
+ val onion = File(source.targets.onionKeyPath)
+ val sources = listOf(
+ BackupSource(StadeBackup.ENTRY_VAULT, meta.length()) { meta.inputStream() },
+ BackupSource(StadeBackup.ENTRY_DB, encrypted.length()) {
+ encrypted.appendBytes(Random(9).nextBytes(64 * 1024))
+ encrypted.inputStream()
+ },
+ BackupSource(StadeBackup.ENTRY_ONION, onion.length()) { onion.inputStream() }
+ )
+
+ val out = ByteArrayOutputStream()
+ BackupArchive.write(out, "a-long-enough-passphrase".toCharArray(), sources)
+
+ val target = Install(tempRoot("grown"))
+ target.root.mkdirs()
+ val scratch = File(tempRoot("scratch2"), "payload.tmp")
+ val outcome = StadeBackup.restore(
+ ByteArrayInputStream(out.toByteArray()),
+ "a-long-enough-passphrase".toCharArray(),
+ target.targets,
+ scratch
+ )
+ assertTrue(outcome is RestoreOutcome.Restored, "expected a restore, got $outcome")
+ assertEquals(db.size.toLong(), File(target.targets.encryptedDbPath).length())
+ }
+
+ @Test
+ fun anEntryLandingExactlyOnAChunkBoundarySurvives() {
+ val source = Install(tempRoot("boundary"))
+ val header = 2 + StadeBackup.ENTRY_VAULT.length + 8
+ val metaLen = BackupArchive.CHUNK_SIZE - header - (2 + StadeBackup.ENTRY_DB.length + 8)
+ source.root.mkdirs()
+ File(source.targets.vaultMetaPath).writeBytes(Random(3).nextBytes(metaLen))
+ val db = Random(4).nextBytes(BackupArchive.CHUNK_SIZE * 2)
+ File(source.targets.encryptedDbPath).writeBytes(db)
+
+ val out = ByteArrayOutputStream()
+ assertTrue(StadeBackup.export(out, "a-long-enough-passphrase".toCharArray(), source.targets))
+
+ val target = Install(tempRoot("boundary-dst"))
+ target.root.mkdirs()
+ val scratch = File(tempRoot("scratch3"), "payload.tmp")
+ val outcome = StadeBackup.restore(
+ ByteArrayInputStream(out.toByteArray()),
+ "a-long-enough-passphrase".toCharArray(),
+ target.targets,
+ scratch
+ )
+ assertTrue(outcome is RestoreOutcome.Restored, "expected a restore, got $outcome")
+ assertContentEquals(db, File(target.targets.encryptedDbPath).readBytes())
+ }
+
+ @Test
+ fun aBackupIsNeverWrittenPartiallyWhenTheSourceShrinksMidStream() {
+ val source = Install(tempRoot("shrink"))
+ val db = Random(21).nextBytes(4 * 1024 * 1024)
+ source.seed(db)
+
+ val meta = File(source.targets.vaultMetaPath)
+ val encrypted = File(source.targets.encryptedDbPath)
+ val declared = encrypted.length()
+ val sources = listOf(
+ BackupSource(StadeBackup.ENTRY_VAULT, meta.length()) { meta.inputStream() },
+ BackupSource(StadeBackup.ENTRY_DB, declared) {
+ encrypted.writeBytes(Random(22).nextBytes(1024))
+ encrypted.inputStream()
+ }
+ )
+
+ val out = ByteArrayOutputStream()
+ val threw = runCatching {
+ BackupArchive.write(out, "a-long-enough-passphrase".toCharArray(), sources)
+ }.isFailure
+ assertTrue(threw, "a shrinking source must abort the export")
+
+ val target = Install(tempRoot("shrink-dst"))
+ target.root.mkdirs()
+ val scratch = File(tempRoot("scratch4"), "payload.tmp")
+ val outcome = StadeBackup.restore(
+ ByteArrayInputStream(out.toByteArray()),
+ "a-long-enough-passphrase".toCharArray(),
+ target.targets,
+ scratch
+ )
+ assertTrue(
+ outcome is RestoreOutcome.Failed,
+ "the half-written archive must not look restorable, got $outcome"
+ )
+ }
+
+ @Test
+ fun theEncryptedDatabaseIsNeverMissingWhileItIsSwappedOut() {
+ val root = tempRoot("swap")
+ val dest = File(root, "stade.db.enc")
+ dest.writeBytes(Random(31).nextBytes(4096))
+ val tmp = File(root, "stade.db.enc.tmp")
+ tmp.writeBytes(Random(32).nextBytes(8192))
+ assertTrue(tmp.renameTo(dest), "rename must replace the target in one step")
+ assertTrue(dest.isFile)
+ assertEquals(8192L, dest.length())
+ }
+}
diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt
index c249eb2..090d08a 100644
--- a/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt
+++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt
@@ -269,6 +269,8 @@ class FileVault(private val rootDir: File) : Vault {
cached = meta
}
+ override fun withFlushLock(block: () -> T): T = synchronized(flushLock) { block() }
+
override fun flushAndKeep() {
synchronized(flushLock) {
val key = dek ?: return
@@ -389,6 +391,7 @@ class FileVault(private val rootDir: File) : Vault {
os.write(nonce)
os.write(ct)
}
+ if (tmp.renameTo(dest)) return
if (dest.exists()) dest.delete()
if (!tmp.renameTo(dest)) {
tmp.copyTo(dest, overwrite = true)
diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/LanTransport.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/LanTransport.kt
index 34f95d5..759ad11 100644
--- a/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/LanTransport.kt
+++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/LanTransport.kt
@@ -10,6 +10,7 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readFully
import io.ktor.utils.io.writeFully
+import dev.stade.backgroundFailures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -31,7 +32,7 @@ class LanTransport(
private val discoveryPort: Int = 5902
) : BaseTransport(TransportType.LAN, "LAN"), DiscoverableTransport {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
private val selector = SelectorManager(Dispatchers.IO)
private var server: ServerSocket? = null
@Volatile private var actualPort: Int = tcpPort
@@ -195,7 +196,7 @@ private class DiscoveryService(
private val mutex = Mutex()
private var rxJob: Job? = null
private var txJob: Job? = null
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
private var receiver: DatagramSocket? = null
private var sender: DatagramSocket? = null
diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/TorTransport.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/TorTransport.kt
index 7681071..8b81468 100644
--- a/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/TorTransport.kt
+++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/TorTransport.kt
@@ -8,6 +8,7 @@ import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.readFully
import io.ktor.utils.io.writeFully
+import dev.stade.backgroundFailures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -29,7 +30,7 @@ class TorTransport(
private val embedded: EmbeddedTorRuntime? = null
) : BaseTransport(TransportType.TOR, "Tor") {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
private val selector = SelectorManager(Dispatchers.IO)
private val mutex = Mutex()
@Volatile private var inboundOnion: String? = null
diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/tor/EmbeddedTorManager.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/tor/EmbeddedTorManager.kt
index f97229d..b4abcf5 100644
--- a/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/tor/EmbeddedTorManager.kt
+++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/transport/tor/EmbeddedTorManager.kt
@@ -2,6 +2,7 @@ package dev.stade.transport.tor
import java.io.File
import java.net.ServerSocket
+import dev.stade.backgroundFailures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -23,7 +24,7 @@ class EmbeddedTorManager(
private val mutex = Mutex()
private val status = MutableStateFlow(TorStatus.Idle)
override val statusFlow: StateFlow = status.asStateFlow()
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + backgroundFailures)
@Volatile private var process: Process? = null
@Volatile private var ready: TorReady? = null
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7741c5c..9859169 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,6 +9,7 @@ ktor = "3.0.1"
sqldelight = "2.0.2"
androidx-activity = "1.9.3"
androidx-core = "1.13.1"
+androidx-biometric = "1.1.0"
bouncycastle = "1.84"
slf4j = "2.0.16"
concentus = "1.0.2"
@@ -30,6 +31,7 @@ sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", ver
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" }
bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
concentus = { module = "io.github.jaredmdobson:concentus", version.ref = "concentus" }
diff --git a/i18n/ar.json b/i18n/ar.json
index 7de978a..e0b12a4 100644
--- a/i18n/ar.json
+++ b/i18n/ar.json
@@ -312,6 +312,20 @@
"duressPinInfoTitle": "كيف يعمل رمز الإكراه",
"duressPinInfoBody": "أدخل هذا الرمز في شاشة القفل بدلًا من رمزك الحقيقي، فيمحو Stade فورًا كل شيء على هذا الجهاز — هويتك وجهات اتصالك ورسائلك ومجموعاتك واستادياتك. عندها يبدو التطبيق تمامًا كتثبيت جديد، دون أي أثر يدل على استخدام رمز إكراه. لا تحذير ولا تأكيد، ولا شيء يمكن استرجاعه بعدها. ولا يُبلَّغ جهات اتصالك.",
"clearDuressPinAction": "إزالة رمز الإكراه",
+ "biometricUnlockTitle": "الفتح ببصمة الإصبع",
+ "biometricUnlockOnSubtitle": "يمكن لبصمتك فتح Stade بدلًا من رمز PIN",
+ "biometricUnlockOffSubtitle": "يلزم رمز PIN في كل مرة",
+ "biometricNotEnrolledSubtitle": "أضف بصمة إصبع من إعدادات جهازك أولًا",
+ "biometricPromptTitle": "فتح قفل Stade",
+ "biometricPromptSubtitle": "أكّد بصمتك للمتابعة",
+ "biometricEnablePromptSubtitle": "أكّد بصمتك لتفعيل هذا الخيار",
+ "biometricUsePinAction": "المتابعة برمز PIN",
+ "biometricUseFingerprintAction": "استخدام البصمة",
+ "biometricConfirmPinTitle": "أكّد رمز PIN",
+ "biometricConfirmPinBody": "أدخل رمز PIN مرة واحدة. يُختم بمفتاح لا تُطلقه إلا بصمتك، ولا يغادر هذا الجهاز أبدًا.",
+ "biometricEnabledNotice": "الفتح ببصمة الإصبع مُفعَّل",
+ "biometricFailedNotice": "تعذّر التحقق من البصمة. استخدم رمز PIN.",
+ "biometricResetNotice": "أُوقف الفتح بالبصمة لأن بصمات هذا الجهاز تغيّرت. أدخل رمز PIN.",
"autoLockNeverInfoTitle": "عن خيار «أبدًا»",
"autoLockNeverInfoBody": "عند ضبطه على «أبدًا»، لا يُطلب رمز PIN عند العودة إلى التطبيق من الخلفية.\n\nلكن إذا أُغلق التطبيق تمامًا وأُعيد تشغيله — حتى لو أزاله النظام من الذاكرة — فستظل بحاجة إلى إدخال رمزك.",
"understood": "فهمت",
diff --git a/i18n/en.json b/i18n/en.json
index 8268aa6..1578789 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -312,6 +312,20 @@
"duressPinInfoTitle": "How the duress PIN works",
"duressPinInfoBody": "Enter this PIN on the lock screen instead of your real one and Stade immediately erases everything on this device — your identity, contacts, messages, groups and Stadiums. The app then looks exactly like a fresh install, with no sign a duress PIN was used. There is no warning, no confirmation, and nothing can be recovered afterwards. Your contacts are not notified.",
"clearDuressPinAction": "Remove duress PIN",
+ "biometricUnlockTitle": "Unlock with fingerprint",
+ "biometricUnlockOnSubtitle": "Your fingerprint can unlock Stade instead of your PIN",
+ "biometricUnlockOffSubtitle": "Your PIN is required every time",
+ "biometricNotEnrolledSubtitle": "Add a fingerprint in your device settings first",
+ "biometricPromptTitle": "Unlock Stade",
+ "biometricPromptSubtitle": "Confirm your fingerprint to continue",
+ "biometricEnablePromptSubtitle": "Confirm your fingerprint to turn this on",
+ "biometricUsePinAction": "Continue with PIN",
+ "biometricUseFingerprintAction": "Use fingerprint",
+ "biometricConfirmPinTitle": "Confirm your PIN",
+ "biometricConfirmPinBody": "Enter your PIN once. It is sealed with a key your fingerprint releases, and never leaves this device.",
+ "biometricEnabledNotice": "Fingerprint unlock is on",
+ "biometricFailedNotice": "Fingerprint could not be verified. Use your PIN.",
+ "biometricResetNotice": "Fingerprint unlock was turned off because the fingerprints on this device changed. Enter your PIN.",
"autoLockNeverInfoTitle": "About the 'Never' Option",
"autoLockNeverInfoBody": "When set to 'Never', no PIN is required when returning to the app from the background.\n\nHowever, if the app is fully closed and restarted — even if cleared from memory by the system — you will still need to enter your PIN.",
"understood": "Got it",
diff --git a/i18n/fa.json b/i18n/fa.json
index 83ef15f..9417de9 100644
--- a/i18n/fa.json
+++ b/i18n/fa.json
@@ -312,6 +312,20 @@
"duressPinInfoTitle": "پین اضطراری چگونه کار میکند",
"duressPinInfoBody": "این پین را بهجای پین واقعی در صفحهٔ قفل وارد کنید و Stade بلافاصله همهچیز را روی این دستگاه پاک میکند — هویت، مخاطبان، پیامها، گروهها و استادیومهای شما. سپس برنامه دقیقاً مثل یک نصب تازه به نظر میرسد، بدون هیچ نشانهای از استفاده از پین اضطراری. هیچ هشدار و تأییدی در کار نیست و بعد از آن هیچچیز قابل بازیابی نیست. به مخاطبان شما اطلاع داده نمیشود.",
"clearDuressPinAction": "حذف پین اضطراری",
+ "biometricUnlockTitle": "باز کردن با اثر انگشت",
+ "biometricUnlockOnSubtitle": "اثر انگشت شما میتواند بهجای پین، قفل Stade را باز کند",
+ "biometricUnlockOffSubtitle": "هر بار پین لازم است",
+ "biometricNotEnrolledSubtitle": "نخست از تنظیمات دستگاه خود یک اثر انگشت اضافه کنید",
+ "biometricPromptTitle": "باز کردن قفل Stade",
+ "biometricPromptSubtitle": "برای ادامه اثر انگشت خود را تأیید کنید",
+ "biometricEnablePromptSubtitle": "برای روشن کردن این گزینه اثر انگشت خود را تأیید کنید",
+ "biometricUsePinAction": "ادامه با پین",
+ "biometricUseFingerprintAction": "استفاده از اثر انگشت",
+ "biometricConfirmPinTitle": "پین خود را تأیید کنید",
+ "biometricConfirmPinBody": "پین خود را یک بار وارد کنید. با کلیدی مهر و موم میشود که تنها اثر انگشت شما آن را آزاد میکند و هرگز از این دستگاه بیرون نمیرود.",
+ "biometricEnabledNotice": "باز کردن با اثر انگشت روشن است",
+ "biometricFailedNotice": "اثر انگشت تأیید نشد. از پین خود استفاده کنید.",
+ "biometricResetNotice": "چون اثر انگشتهای این دستگاه تغییر کرده، باز کردن با اثر انگشت خاموش شد. پین خود را وارد کنید.",
"autoLockNeverInfoTitle": "دربارهٔ گزینهٔ «هرگز»",
"autoLockNeverInfoBody": "وقتی روی «هرگز» تنظیم شود، هنگام بازگشت به برنامه از پسزمینه پینی خواسته نمیشود.\n\nبا این حال، اگر برنامه کاملاً بسته و دوباره اجرا شود — حتی اگر سیستم آن را از حافظه پاک کرده باشد — باز هم باید پین خود را وارد کنید.",
"understood": "متوجه شدم",
diff --git a/i18n/ko.json b/i18n/ko.json
index 2d9932f..8c6c563 100644
--- a/i18n/ko.json
+++ b/i18n/ko.json
@@ -312,6 +312,20 @@
"duressPinInfoTitle": "비상 PIN 작동 방식",
"duressPinInfoBody": "잠금 화면에서 실제 PIN 대신 이 PIN을 입력하면 Stade가 이 기기의 모든 것을 즉시 지웁니다 — 신원, 연락처, 메시지, 그룹, Stadium까지 전부입니다. 그 후 앱은 새로 설치한 것과 똑같아 보이며, 비상 PIN이 사용되었다는 흔적도 남지 않습니다. 경고도 확인 절차도 없고, 이후에는 아무것도 복구할 수 없습니다. 연락처에도 알림이 가지 않습니다.",
"clearDuressPinAction": "비상 PIN 삭제",
+ "biometricUnlockTitle": "지문으로 잠금 해제",
+ "biometricUnlockOnSubtitle": "PIN 대신 지문으로 Stade 잠금을 해제할 수 있습니다",
+ "biometricUnlockOffSubtitle": "매번 PIN이 필요합니다",
+ "biometricNotEnrolledSubtitle": "먼저 기기 설정에서 지문을 등록하세요",
+ "biometricPromptTitle": "Stade 잠금 해제",
+ "biometricPromptSubtitle": "계속하려면 지문을 확인하세요",
+ "biometricEnablePromptSubtitle": "이 기능을 켜려면 지문을 확인하세요",
+ "biometricUsePinAction": "PIN으로 계속",
+ "biometricUseFingerprintAction": "지문 사용",
+ "biometricConfirmPinTitle": "PIN 확인",
+ "biometricConfirmPinBody": "PIN을 한 번 입력하세요. 지문으로만 열리는 키로 봉인되며 이 기기를 벗어나지 않습니다.",
+ "biometricEnabledNotice": "지문 잠금 해제가 켜졌습니다",
+ "biometricFailedNotice": "지문을 확인할 수 없습니다. PIN을 사용하세요.",
+ "biometricResetNotice": "이 기기의 지문이 변경되어 지문 잠금 해제가 꺼졌습니다. PIN을 입력하세요.",
"autoLockNeverInfoTitle": "'안 함' 옵션에 대하여",
"autoLockNeverInfoBody": "'안 함'으로 설정하면 백그라운드에서 앱으로 돌아올 때 PIN이 필요하지 않습니다.\n\n다만 앱이 완전히 종료되었다가 다시 실행되는 경우에는 — 시스템이 메모리에서 정리한 경우라도 — PIN을 입력해야 합니다.",
"understood": "확인",
diff --git a/i18n/tr.json b/i18n/tr.json
index a0d5383..8d2ec6e 100644
--- a/i18n/tr.json
+++ b/i18n/tr.json
@@ -312,6 +312,20 @@
"duressPinInfoTitle": "Tehlike PIN’i nasıl çalışır",
"duressPinInfoBody": "Kilit ekranında gerçek PIN’in yerine bunu girersen Stade bu cihazdaki her şeyi anında siler — kimliğin, kişilerin, mesajların, grupların ve stadiumların. Uygulama ardından yeni kurulmuş gibi görünür; Tehlike PIN’i kullanıldığına dair bir iz kalmaz. Uyarı ya da onay yoktur ve sonrasında hiçbir şey geri getirilemez. Kişilerine bildirim gitmez.",
"clearDuressPinAction": "Tehlike PIN'ini kaldır",
+ "biometricUnlockTitle": "Parmak iziyle aç",
+ "biometricUnlockOnSubtitle": "Parmak iziniz, PIN yerine Stade'in kilidini açabilir",
+ "biometricUnlockOffSubtitle": "Her seferinde PIN gerekir",
+ "biometricNotEnrolledSubtitle": "Önce cihaz ayarlarınızdan bir parmak izi ekleyin",
+ "biometricPromptTitle": "Stade'in kilidini aç",
+ "biometricPromptSubtitle": "Devam etmek için parmak izinizi doğrulayın",
+ "biometricEnablePromptSubtitle": "Bunu açmak için parmak izinizi doğrulayın",
+ "biometricUsePinAction": "PIN ile devam et",
+ "biometricUseFingerprintAction": "Parmak izini kullan",
+ "biometricConfirmPinTitle": "PIN'inizi doğrulayın",
+ "biometricConfirmPinBody": "PIN'inizi bir kez girin. Parmak izinizin açtığı bir anahtarla mühürlenir ve bu cihazdan hiç çıkmaz.",
+ "biometricEnabledNotice": "Parmak iziyle açma etkin",
+ "biometricFailedNotice": "Parmak izi doğrulanamadı. PIN'inizi kullanın.",
+ "biometricResetNotice": "Bu cihazdaki parmak izleri değiştiği için parmak iziyle açma kapatıldı. PIN'inizi girin.",
"autoLockNeverInfoTitle": "«Asla» Seçeneği Hakkında",
"autoLockNeverInfoBody": "«Asla» seçildiğinde, uygulama arka planda çalışırken öne alındığında PIN istenmez.\n\nAncak uygulama tamamen kapatılıp yeniden açıldığında — sistem tarafından bellekten temizlenmiş olsa dahi — şifrenizi girmeniz gerekir.",
"understood": "Anladım",