Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/macos-package-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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"
Expand All @@ -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()
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

<uses-feature android:name="android.hardware.fingerprint" android:required="false" />
<uses-feature android:name="android.hardware.biometrics" android:required="false" />

<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dev.stade

import android.util.Log

actual fun reportBackgroundFailure(throwable: Throwable) {
Log.e("Stade", "background task failed", throwable)
}
4 changes: 2 additions & 2 deletions composeApp/src/androidMain/kotlin/dev/stade/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand All @@ -21,7 +21,7 @@ import dev.stade.service.StadeService
import dev.stade.ui.StadeApp
import java.io.ByteArrayOutputStream

class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import dev.stade.backgroundFailures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -64,7 +65,7 @@ internal class AndroidRadarSession(

private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
private val adapter: BluetoothAdapter? get() = manager?.adapter
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + backgroundFailures)

private val lock = Any()
private val seen = LinkedHashMap<String, RadarPeer>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ByteArray, ByteArray>? {
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) }
}
Loading
Loading