A comprehensive Kotlin Multiplatform demo application showcasing KSafe - a secure encrypted storage library with biometric authentication, runtime security detection, and device lock-state protection.
Platforms: Android, iOS, Desktop (JVM), Browser (WASM/JS)
| Storage Screen | Custom JSON Screen | Security Screen |
|---|---|---|
![]() |
(screenshot pending) | ![]() |
This demo application serves as a practical guide to understanding and implementing KSafe in your own projects. It demonstrates:
- Compose State Integration: Using
ksafe.mutableStateOf()for reactive, encrypted persistence - Property Delegation: Using
by ksafe()for non-Compose encrypted properties - Encryption Options: Both encrypted (default) and unencrypted storage modes
- Complex Data Types: Storing
@Serializabledata classes with automatic serialization
- Cross-Platform Support: Face ID, Touch ID, and Fingerprint authentication
- Authorization Duration: Caching authentication for a specified time period with visible countdown
- Scoped Authentication: Binding auth validity to ViewModel lifecycle
- Runtime Security Detection: Root/Jailbreak, Debugger, Debug Build, Emulator
- Configurable Actions: IGNORE, WARN, or BLOCK for each security check
- Security Callbacks: Custom handling when violations are detected
- @Contextual Types: Storing data classes with third-party types you don't own (e.g.,
UUID,Instant) - Custom SerializersModule: Registering custom serializers once at the KSafe instance level
- Both Modes: Works with encrypted and plain-text storage
- Code Snippets: The screen itself displays the setup code for reference
- Browser localStorage: Encrypted key-value storage in the browser via WebCrypto AES-256-GCM
- Async Cache Initialization:
awaitCacheReady()gates rendering until WebCrypto decryption completes - Same API: All KSafe features (property delegation, Compose state, StateFlow) work identically in the browser
- Compose for Web: Full
mutableStateOfpersistence viaksafe-composeWASM target
requireUnlockedDevice: Encrypted data is only accessible when the device is unlocked- Interactive Lock Test: 15-second countdown to lock your device, then verifies encrypted reads are blocked
- Platform Background Tasks: iOS uses
beginBackgroundTaskWithExpirationHandlerto keep the test running while the screen is off
Demonstrates various ways to persist data with KSafe:
| Feature | Description |
|---|---|
| Counter 1 | Regular Compose mutableStateOf - no persistence (resets on restart) |
| Counter 2 | ksafe.mutableStateOf - encrypted persistent state |
| Counter 3 | ksafe.mutableStateOf with encrypted = false - unencrypted persistent state |
| AuthInfo | @Serializable data class with encrypted persistence |
| Biometric Count | Counter protected by biometric authentication with authorization duration countdown |
| Lock Test | Interactive test to verify requireUnlockedDevice blocks access when the device is locked |
Demonstrates storing data classes that contain @Contextual types — types you don't own and can't annotate with @Serializable:
| Feature | Description |
|---|---|
| Two custom types | Timestamp (Long) and HexColor (String) — stand-ins for types like Instant and Color |
| Two @Contextual fields | UserProfile with @Contextual val createdAt and @Contextual val favoriteColor |
| Encrypted + Plain | Same data stored in both modes to show it works everywhere |
| Step-by-step code | The screen itself displays the 4-step setup as inline code snippets |
Displays real-time security status of the device:
| Check | Description |
|---|---|
| Root/Jailbreak | Detects rooted Android devices or jailbroken iOS devices |
| Debugger | Detects if a debugger is attached to the process |
| Debug Build | Detects if the app is running in debug mode |
| Emulator | Detects if running on emulator/simulator |
class LibCounterViewModel(val ksafe: KSafe) : ViewModel() {
// Regular Compose state - no persistence
var count1 by mutableStateOf(1000)
private set
// KSafe encrypted state - persists across app restarts
var count2 by ksafe.mutableStateOf(2000)
private set
// KSafe unencrypted state with custom key
var count3 by ksafe.mutableStateOf(
defaultValue = 3000,
key = "counter3Key",
encrypted = false
)
private set
}// Encrypted by default
var count4 by ksafe(10)
var count5 by ksafe(20)
// Encrypted string
var count6 by ksafe("30")
// Unencrypted string
var count7 by ksafe("40", encrypted = false)@Serializable
data class AuthInfo(
val accessToken: String = "",
val refreshToken: String = "",
val expiresIn: Long = 0L
)
var authInfo by ksafe.mutableStateOf(
defaultValue = AuthInfo(
accessToken = "abc",
refreshToken = "def",
expiresIn = 3600L
),
key = "authInfo",
encrypted = true
)private val bioAuthDurationSeconds = 6
fun bioCounterIncrement() {
ksafe.verifyBiometricDirect(
reason = "Authenticate to save",
authorizationDuration = BiometricAuthorizationDuration(
duration = bioAuthDurationSeconds * 1000L,
scope = viewModelScope.hashCode().toString()
)
) { success ->
if (success) {
bioCount++
// Start visible countdown only on fresh auth
if (bioAuthRemaining == 0) {
startBioAuthTimer()
}
}
}
}// 1. Define custom serializers for types you don't own
object TimestampSerializer : KSerializer<Timestamp> {
override val descriptor = PrimitiveSerialDescriptor("Timestamp", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Timestamp) = encoder.encodeLong(value.epochMillis)
override fun deserialize(decoder: Decoder) = Timestamp(decoder.decodeLong())
}
object HexColorSerializer : KSerializer<HexColor> {
override val descriptor = PrimitiveSerialDescriptor("HexColor", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: HexColor) = encoder.encodeString(value.hex)
override fun deserialize(decoder: Decoder) = HexColor(decoder.decodeString())
}
// 2. Register all serializers in one place
val customJson = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(TimestampSerializer)
contextual(HexColorSerializer)
// add as many as you need
}
}
// 3. Pass it via KSafeConfig — one setup, used everywhere
val ksafe = KSafe(
config = KSafeConfig(json = customJson)
)
// 4. Use @Contextual types directly — no extra work at the call site
@Serializable
data class UserProfile(
val name: String,
@Contextual val createdAt: Timestamp,
@Contextual val favoriteColor: HexColor
)
var profile by ksafe.mutableStateOf(
defaultValue = defaultProfile,
key = "custom_json_profile",
mode = KSafeWriteMode.Encrypted()
)Note:
kotlinx-serialization-jsonis provided as a transitive dependency by KSafe — no need to add it manually.
actual val platformModule: Module
get() = module {
single<KSafe> {
KSafe(
context = androidApplication(),
config = KSafeConfig(requireUnlockedDevice = true),
securityPolicy = KSafeSecurityPolicy.Strict.copy(
debuggerAttached = SecurityAction.WARN,
debugBuild = SecurityAction.WARN,
emulator = SecurityAction.WARN,
onViolation = { violation ->
SecurityViolationsHolder.addViolation(violation)
}
)
)
}
}actual val platformModule: Module
get() = module {
single<KSafe> {
KSafe(
fileName = "wasmdata",
securityPolicy = KSafeSecurityPolicy.WarnOnly.copy(
onViolation = { violation ->
SecurityViolationsHolder.addViolation(violation)
}
)
)
}
}fun main() {
val body = document.body ?: return
ComposeViewport(body) {
KoinMultiplatformApplication(config = createKoinConfiguration()) {
var cacheReady by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val ksafe: KSafe = getKoin().get()
ksafe.awaitCacheReady()
cacheReady = true
}
if (cacheReady) {
AppContent()
}
}
}
}Note: On WASM, Koin must be initialized before
awaitCacheReady()can retrieve the KSafe instance. TheAppContent()composable (extracted fromApp()) renders only after the async WebCrypto initialization completes.
viewModelScope.launch {
ksafe.getFlow<String?>(
key = "access-token",
defaultValue = null,
encrypted = true
).collect { value ->
println("Token changed: $value")
}
}KSafe 1.4.0 introduces configurable security policies:
| Policy | Root/Jailbreak | Debugger | Debug Build | Emulator |
|---|---|---|---|---|
| Default | IGNORE | IGNORE | IGNORE | IGNORE |
| WarnOnly | WARN | WARN | WARN | WARN |
| Strict | BLOCK | BLOCK | WARN | WARN |
| Action | Behavior |
|---|---|
IGNORE |
No detection performed |
WARN |
Detection runs, callback invoked, app continues |
BLOCK |
Detection runs, callback invoked, throws SecurityViolationException |
The demo includes an interactive test for the requireUnlockedDevice feature:
- Tap "Test Lock Feature" - a test value is pre-stored while the device is unlocked
- A 15-second countdown begins - lock your device during this time
- After the countdown, the app attempts to read the encrypted value from the Keychain/Keystore
- Results:
- "READ BLOCKED" - The feature works correctly; the Keychain/Keystore denied access while locked
- "READ SUCCEEDED" - If running from Xcode with the debugger, this is expected (see below)
Important (iOS): Xcode's debugger prevents iOS data protection from fully engaging. For accurate results:
- Build & run the app on your device from Xcode
- Press Stop in Xcode to disconnect the debugger
- Launch the app from the Home Screen
- Run the lock test
This demo implements cross-platform biometric authentication using the expect/actual pattern:
commonMain/
└── biometric/
└── BiometricAuthenticator.kt # Interface + expect function
androidMain/
└── biometric/
└── BiometricAuthenticator.android.kt # BiometricPrompt
iosMain/
└── biometric/
└── BiometricAuthenticator.ios.kt # LocalAuthentication (Face ID/Touch ID)
jvmMain/
└── biometric/
└── BiometricAuthenticator.jvm.kt # Auto-authenticate (no hardware)
interface BiometricAuthenticator {
fun isAvailable(): Boolean
fun authenticate(
title: String,
subtitle: String,
onSuccess: () -> Unit,
onError: (String) -> Unit
)
}
@Composable
expect fun rememberBiometricAuthenticator(): BiometricAuthenticatorcomposeApp/src/
├── commonMain/kotlin/eu/anifantakis/ksafe_demo/
│ ├── App.kt # Navigation with bottom tabs
│ ├── biometric/
│ │ └── BiometricAuthenticator.kt # expect interface
│ ├── di/
│ │ ├── Modules.kt # Shared DI module
│ │ └── SecurityViolationsHolder.kt # Security violations state
│ ├── util/
│ │ └── BackgroundTask.kt # expect for platform background tasks
│ └── screens/
│ ├── counters/
│ │ ├── LibCounterScreen.kt # Storage demo UI
│ │ └── LibCounterViewModel.kt # Storage demo logic
│ ├── customjson/
│ │ ├── CustomJsonScreen.kt # Custom JSON demo UI
│ │ └── CustomJsonViewModel.kt # @Contextual types + custom SerializersModule
│ └── security/
│ ├── SecurityScreen.kt # Security status UI
│ └── SecurityViewModel.kt # Security status logic
│
├── androidMain/kotlin/eu/anifantakis/ksafe_demo/
│ ├── MainActivity.kt
│ ├── biometric/BiometricAuthenticator.android.kt
│ ├── util/BackgroundTask.android.kt # No-op (Android doesn't suspend)
│ └── di/Modules.android.kt # KSafe with requireUnlockedDevice
│
├── iosMain/kotlin/eu/anifantakis/ksafe_demo/
│ ├── MainViewController.kt
│ ├── biometric/BiometricAuthenticator.ios.kt
│ ├── util/BackgroundTask.ios.kt # beginBackgroundTask for lock test
│ └── di/Modules.ios.kt
│
├── jvmMain/kotlin/eu/anifantakis/ksafe_demo/
│ ├── main.kt
│ ├── biometric/BiometricAuthenticator.jvm.kt
│ ├── util/BackgroundTask.jvm.kt # No-op
│ └── di/Modules.jvm.kt
│
└── wasmJsMain/kotlin/eu/anifantakis/ksafe_demo/
├── main.kt # ComposeViewport + awaitCacheReady
├── util/BackgroundTask.wasmJs.kt # No-op
└── di/Modules.wasmJs.kt # KSafe with localStorage + WebCrypto
./gradlew :composeApp:assembleDebug
./gradlew :composeApp:installDebug./gradlew :composeApp:runOpen iosApp/iosApp.xcodeproj in Xcode and run.
./gradlew :composeApp:wasmJsBrowserDevelopmentRunThen open http://localhost:8080/ in your browser.
// build.gradle.kts
commonMain.dependencies {
implementation("eu.anifantakis:ksafe:1.7.1")
implementation("eu.anifantakis:ksafe-compose:1.7.1")
}
kotlinx-serialization-jsonand biometric support (androidx.biometric) are included transitively through KSafe — no explicit dependencies needed.
- Seamless Encryption: KSafe makes encrypted storage as simple as regular storage
- Compose Integration:
mutableStateOfworks exactly like Compose's native state - Cross-Platform: Same API across Android, iOS, Desktop, and Browser
- Security-First: Runtime security detection helps protect sensitive data
- Biometric Ready: Built-in support for biometric authentication with duration caching
- Lock-State Protection:
requireUnlockedDeviceensures encrypted data is inaccessible when the device is locked - Custom JSON: Support for
@Contextualtypes via customSerializersModule— store any type
This demo is provided as-is for educational purposes. See the KSafe library for licensing information.

