Implement full encryption protocol and haptic events on iOS

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-08 16:51:36 +03:00
Unverified
parent bb6cdfadbb
commit e10f19d1e1
12 changed files with 463 additions and 94 deletions
+4
View File
@@ -25,6 +25,7 @@ kotlin {
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
isStatic = true isStatic = true
linkerOpts("-framework", "UIKit")
} }
} }
@@ -77,12 +78,15 @@ kotlin {
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
implementation(libs.krypto) implementation(libs.krypto)
implementation(libs.cryptography.core)
implementation(libs.cryptography.provider.optimal)
} }
iosMain.dependencies { iosMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.darwin) implementation(libs.ktor.client.darwin)
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.5")
} }
} }
} }
@@ -3,34 +3,29 @@ package ru.fromchat.api
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.Darwin import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.defaultRequest
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.http.headers
actual fun createPlatformHttpClient( actual fun createPlatformHttpClient(
block: HttpClientConfig<*>.() -> Unit block: HttpClientConfig<*>.() -> Unit
): HttpClient { ): HttpClient {
@Suppress("UNCHECKED_CAST")
return HttpClient(Darwin) { return HttpClient(Darwin) {
// Configure default request headers to ensure UTF-8 encoding
defaultRequest { defaultRequest {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
headers { headers {
append("Accept-Charset", "utf-8") append("Accept-Charset", "utf-8")
append("Content-Type", "application/json; charset=utf-8") append("Content-Type", "application/json; charset=utf-8")
} }
} }
// Add timeout configuration
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 30000 requestTimeoutMillis = 30000
connectTimeoutMillis = 30000 connectTimeoutMillis = 30000
} }
// Apply the passed configuration block
block(this) block(this)
} }
} }
@@ -1,29 +1,81 @@
package ru.fromchat.crypto.backup package ru.fromchat.crypto.backup
import dev.whyoleg.cryptography.BinarySize.Companion.bits
import dev.whyoleg.cryptography.BinarySize.Companion.bytes
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.algorithms.AES
import dev.whyoleg.cryptography.algorithms.PBKDF2
import dev.whyoleg.cryptography.algorithms.SHA256
import dev.whyoleg.cryptography.random.CryptographyRandom
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.random.Random
private const val PBKDF2_ITERATIONS = 210_000
private const val IV_SIZE = 12
private const val KEY_SIZE_BYTES = 32
@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class)
actual object BackupCrypto { actual object BackupCrypto {
private val provider get() = CryptographyProvider.Default
private val aesGcm get() = provider.get(AES.GCM)
private val pbkdf2 get() = provider.get(PBKDF2)
actual suspend fun encryptBackupWithPassword( actual suspend fun encryptBackupWithPassword(
password: String, password: String,
bundle: PrivateKeyBundle bundle: PrivateKeyBundle
): EncryptedBackupBlob = withContext(Dispatchers.Default) { ): EncryptedBackupBlob = withContext(Dispatchers.Default) {
// iOS backup encryption is not implemented yet; fail fast if called. val salt = CryptographyRandom.nextBytes(16)
error("Backup encryption is not yet implemented on iOS.") val nonce = CryptographyRandom.nextBytes(IV_SIZE)
EncryptedBackupBlob(
salt,
nonce,
aesGcm
.keyDecoder()
.decodeFromByteArray(
AES.Key.Format.RAW,
pbkdf2
.secretDerivation(
digest = SHA256,
iterations = PBKDF2_ITERATIONS,
outputSize = KEY_SIZE_BYTES.bytes,
salt = salt
)
.deriveSecretBlocking(
password.encodeToByteArray()
)
.toByteArray()
)
.cipher(tagSize = 128.bits)
.encryptWithIv(nonce, serializeBundle(bundle))
)
} }
actual suspend fun decryptBackupWithPassword( actual suspend fun decryptBackupWithPassword(
password: String, password: String,
blob: EncryptedBackupBlob blob: EncryptedBackupBlob
): PrivateKeyBundle = withContext(Dispatchers.Default) { ): PrivateKeyBundle = withContext(Dispatchers.Default) {
// iOS backup decryption is not implemented yet; fail fast if called. deserializeBundle(
error("Backup decryption is not yet implemented on iOS.") aesGcm
.keyDecoder()
.decodeFromByteArray(
AES.Key.Format.RAW,
pbkdf2
.secretDerivation(
digest = SHA256,
iterations = PBKDF2_ITERATIONS,
outputSize = KEY_SIZE_BYTES.bytes,
salt = blob.salt
)
.deriveSecretBlocking(
password.encodeToByteArray()
)
.toByteArray()
)
.cipher(tagSize = 128.bits)
.decryptWithIv(blob.iv, blob.ciphertext)
)
} }
actual fun randomBytes(length: Int): ByteArray { actual fun randomBytes(length: Int): ByteArray = CryptographyRandom.nextBytes(length)
val bytes = ByteArray(length)
Random.Default.nextBytes(bytes)
return bytes
}
} }
@@ -1,79 +1,71 @@
package ru.fromchat.crypto.dm package ru.fromchat.crypto.dm
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import com.pr0gramm3r101.utils.crypto.Hmac import com.pr0gramm3r101.utils.require
import com.pr0gramm3r101.utils.crypto.PasswordHash import dev.whyoleg.cryptography.BinarySize.Companion.bits
import kotlinx.cinterop.ExperimentalForeignApi import dev.whyoleg.cryptography.CryptographyProvider
import kotlinx.cinterop.alloc import dev.whyoleg.cryptography.algorithms.AES
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import platform.CoreCrypto.CCCryptorCreateWithMode
import platform.CoreCrypto.CCCryptorRelease
import platform.CoreCrypto.CCCryptorUpdate
import platform.CoreCrypto.CCCryptorFinal
import platform.CoreCrypto.kCCAlgorithmAES
import platform.CoreCrypto.kCCKeySizeAES256
import platform.CoreCrypto.kCCDecrypt
import platform.CoreCrypto.kCCSuccess
// GCM mode constant (value 11) - CommonCrypto GCM support via cinterop is limited
private const val kCCModeGCM = 11u
actual object DmCrypto {
private const val AES_KEY_SIZE = 32 private const val AES_KEY_SIZE = 32
private const val GCM_IV_SIZE = 12 private const val GCM_IV_SIZE = 12
private const val GCM_TAG_SIZE = 16 private const val GCM_TAG_SIZE = 16
@OptIn(ExperimentalForeignApi::class) @OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class)
actual suspend fun unwrapMek(wrappedMekB64: String, wrappingKey: ByteArray): ByteArray = actual object DmCrypto {
withContext(Dispatchers.Default) { private val provider get() = CryptographyProvider.Default
require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" } private val aesGcm get() = provider.get(AES.GCM)
actual suspend fun unwrapMek(
wrappedMekB64: String,
wrappingKey: ByteArray
) = withContext(Dispatchers.Default) {
require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" }
val wrapped = Base64.decode(wrappedMekB64) val wrapped = Base64.decode(wrappedMekB64)
require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" } require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" }
// Extract IV and ciphertext+tag aesGcmDecrypt(
val iv = wrapped.sliceArray(0 until GCM_IV_SIZE) wrappingKey,
val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size) wrapped.sliceArray(0 until GCM_IV_SIZE),
wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
// Decrypt using AES-GCM )
aesGcmDecrypt(wrappingKey, iv, ciphertext)
} }
@OptIn(ExperimentalForeignApi::class)
actual suspend fun decryptEnvelope( actual suspend fun decryptEnvelope(
ivB64: String, ivB64: String,
ciphertextB64: String, ciphertextB64: String,
mek: ByteArray mek: ByteArray
): ByteArray = withContext(Dispatchers.Default) { ) = withContext(Dispatchers.Default) {
require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" } aesGcmDecrypt(
mek.require("MEK must be 32 bytes") {
val iv = Base64.decode(ivB64) it.size == AES_KEY_SIZE
val ciphertext = Base64.decode(ciphertextB64) },
Base64
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" } .decode(ivB64)
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } .require("IV must be 12 bytes") {
it.size == GCM_IV_SIZE
// Decrypt using AES-GCM },
aesGcmDecrypt(mek, iv, ciphertext) Base64
.decode(ciphertextB64)
.require("Ciphertext too short") {
it.size >= GCM_TAG_SIZE
}
)
} }
@OptIn(ExperimentalForeignApi::class) private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
private fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" } require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes (256 bits)" } require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
// TODO: Implement proper AES-GCM using CommonCrypto or CryptoKit return aesGcm
// See BackupCrypto.ios.kt for implementation notes. .keyDecoder()
// For now, delegate to BackupCrypto's implementation when available. .decodeFromByteArray(
AES.Key.Format.RAW,
error("AES-GCM decryption not yet implemented for iOS. Use CryptoKit via Swift interop or krypto library.") key
)
.cipher(tagSize = 128.bits)
.decryptWithIv(iv, ciphertext)
} }
} }
@@ -1,5 +1,10 @@
package ru.fromchat.crypto.transport package ru.fromchat.crypto.transport
import com.ionspin.kotlin.crypto.LibsodiumInitializer
import com.ionspin.kotlin.crypto.box.Box
import com.ionspin.kotlin.crypto.box.crypto_box_NONCEBYTES
import com.ionspin.kotlin.crypto.util.LibsodiumRandom
import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -7,8 +12,24 @@ actual object TransportCrypto {
actual suspend fun encryptWithTransportKey( actual suspend fun encryptWithTransportKey(
plaintext: String, plaintext: String,
transportPublicKeyB64: String transportPublicKeyB64: String
): TransportCiphertext = withContext(Dispatchers.Default) { ) = withContext(Dispatchers.Default) {
error("TransportCrypto.encryptWithTransportKey is not yet implemented on iOS.") if (!LibsodiumInitializer.isInitialized()) {
} LibsodiumInitializer.initialize()
} }
val keyPair = Box.keypair()
val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES)
val ciphertext = Box.easy(
plaintext.encodeToByteArray().toUByteArray(),
nonce,
Base64.decode(transportPublicKeyB64).toUByteArray(),
keyPair.secretKey
)
TransportCiphertext(
clientPublicKeyB64 = Base64.encode(keyPair.publicKey.toByteArray()),
nonceB64 = Base64.encode(nonce.toByteArray()),
ciphertextB64 = Base64.encode(ciphertext.toByteArray())
)
}
}
@@ -1,3 +1,5 @@
package ru.fromchat.fcm package ru.fromchat.fcm
actual suspend fun uploadPendingFcmTokenIfAvailable() {} actual suspend fun uploadPendingFcmTokenIfAvailable() {
// This should remain a placeholder because iOS is stupid
}
@@ -1,11 +1,27 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.cinterop.ExperimentalForeignApi
import platform.UIKit.UIImpactFeedbackGenerator
import platform.UIKit.UIImpactFeedbackStyle
@Composable @Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
// iOS has no system back button; back is handled by navigation. // iOS has no system back button; back is handled by navigation.
} }
@OptIn(ExperimentalForeignApi::class)
@Composable @Composable
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit = { } actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
return remember {
{ ordinal ->
val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
}
val generator = UIImpactFeedbackGenerator(style)
generator.impactOccurred()
}
}
}
@@ -6,5 +6,4 @@ import androidx.compose.runtime.Composable
@Composable @Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (darkTheme) darkColorScheme() if (darkTheme) darkColorScheme() else lightColorScheme()
else lightColorScheme()
@@ -1,9 +1,291 @@
package ru.fromchat.ui.debug package ru.fromchat.ui.debug
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
actual fun DebugApiScreen() { actual fun DebugApiScreen() {
// iOS debug UI not implemented; Android-only for now. val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var statusMessage by rememberSaveable { mutableStateOf("") }
var profileResult by rememberSaveable { mutableStateOf<String?>(null) }
var conversationsResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyResult by rememberSaveable { mutableStateOf<String?>(null) }
var historyUserId by rememberSaveable { mutableStateOf("0") }
var decryptedMessage by rememberSaveable { mutableStateOf<String?>(null) }
var sendRecipientId by rememberSaveable { mutableStateOf("") }
var sendMessageText by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
title = {
Text(text = "Debug API")
},
actions = {
IconButton(onClick = {}) {
Icon(imageVector = Icons.Filled.BugReport, contentDescription = null)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = statusMessage.takeIf { it.isNotBlank() }
?: "Status will appear here",
style = MaterialTheme.typography.bodyMedium
)
Button(
onClick = {
scope.launch {
runCatching { ApiClient.getOwnProfile() }
.onSuccess {
profileResult = ApiClient.json.encodeToString(it)
statusMessage = "Profile loaded"
}
.onFailure {
statusMessage = it.message ?: "An unknown error occurred"
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Load own profile")
} }
Text(
text = profileResult ?: "No data loaded yet",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
scope.launch {
runCatching { ApiClient.getDmConversations() }
.onSuccess {
conversationsResult = ApiClient.json.encodeToString(it)
statusMessage = "Conversations loaded"
}
.onFailure {
statusMessage = it.message ?: "An unknown error occurred"
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Load DM conversations")
}
Text(
text = conversationsResult ?: "No data loaded yet",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = historyUserId,
onValueChange = { historyUserId = it },
label = { Text(text = "History user ID") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
val userId = historyUserId.toIntOrNull()
if (userId == null) {
statusMessage = "Enter a valid user ID"
return@Button
}
scope.launch {
runCatching { ApiClient.getDmHistory(userId) }
.onSuccess {
historyResult = ApiClient.json.encodeToString(it)
statusMessage = "DM history loaded"
}
.onFailure {
statusMessage = it.message ?: "An unknown error occurred"
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Load DM history")
}
Text(
text = historyResult ?: "No data loaded yet",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = DividerDefaults.color
)
Text(
text = "DM Send Test",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = sendRecipientId,
onValueChange = { sendRecipientId = it },
label = { Text(text = "Recipient user ID") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = sendMessageText,
onValueChange = { sendMessageText = it },
label = { Text(text = "Message text") },
singleLine = false,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
val userId = sendRecipientId.toIntOrNull()
if (userId == null) {
statusMessage = "Enter a valid recipient ID"
return@Button
}
if (sendMessageText.isBlank()) {
statusMessage = "Enter a message to send"
return@Button
}
scope.launch {
runCatching {
ApiClient.sendDm(
recipientId = userId,
plaintext = sendMessageText.trim(),
replyToId = null
)
}.onSuccess {
statusMessage = "DM sent successfully"
}.onFailure {
statusMessage = it.message ?: "Failed to send DM"
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Send DM")
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness,
color = DividerDefaults.color
)
Text(
text = "DM Decryption Test",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
val userId = historyUserId.toIntOrNull()
if (userId == null) {
statusMessage = "Enter a valid user ID"
return@Button
}
scope.launch {
try {
IdentityKeyManager.restoreFromLocal()
val history = ApiClient.getDmHistory(userId)
if (history.messages.isEmpty()) {
statusMessage = "No messages found"
decryptedMessage = null
return@launch
}
val currentUserId = settings.getInt("current_user_id", 0)
if (currentUserId == 0) {
statusMessage = "Current user ID not found"
decryptedMessage = null
return@launch
}
val firstEnvelope = history.messages.first()
val plaintext = decryptEnvelope(firstEnvelope, currentUserId)
decryptedMessage = "Decrypted: $plaintext"
statusMessage = "Decryption successful"
} catch (e: Exception) {
statusMessage = "Decryption failed: ${e.message}"
decryptedMessage = "Error: ${e.message}\n${e.stackTraceToString()}"
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Decrypt First DM")
}
Text(
text = decryptedMessage ?: "No decrypted message yet",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth()
)
}
}
}
+10 -8
View File
@@ -1,6 +1,6 @@
[versions] [versions]
agp = "9.0.0" agp = "9.0.0"
androidx-activityCompose = "1.12.2" androidx-activityCompose = "1.12.3"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.17.0" androidx-core-ktx = "1.17.0"
coilCompose = "3.3.0" coilCompose = "3.3.0"
@@ -11,7 +11,7 @@ coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.1" firebaseMessaging = "25.0.1"
googleServices = "4.4.4" googleServices = "4.4.4"
haze = "1.7.1" haze = "1.7.1"
kotlin = "2.3.0" kotlin = "2.3.10"
adaptiveAndroid = "1.2.0" adaptiveAndroid = "1.2.0"
biometric = "1.4.0-alpha05" biometric = "1.4.0-alpha05"
gson = "2.13.2" gson = "2.13.2"
@@ -19,19 +19,19 @@ kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2" kotlinxIoCore = "0.8.2"
multiplatformSettings = "1.3.0" multiplatformSettings = "1.3.0"
serialization = "2.3.0" serialization-json = "1.10.0"
serialization-json = "1.9.0"
material = "1.13.0" material = "1.13.0"
activityKtx = "1.12.2" activityKtx = "1.12.3"
navigationCompose = "2.9.1" navigationCompose = "2.9.1"
datastore = "1.2.0" datastore = "1.2.0"
security-crypto = "1.1.0" security-crypto = "1.1.0"
ktor = "3.3.3" ktor = "3.4.0"
slf4j = "1.7.36" slf4j = "1.7.36"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.7.1"
cryptography-kotlin = "0.5.0"
krypto = "4.0.10" krypto = "4.0.10"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.01.00" composeBom = "2026.01.01"
composeMaterialIconsExtended = "1.7.3" composeMaterialIconsExtended = "1.7.3"
composeMaterial3 = "1.10.0-alpha05" composeMaterial3 = "1.10.0-alpha05"
composeComponents = "1.10.0" composeComponents = "1.10.0"
@@ -91,6 +91,8 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" } compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" } krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-kotlin" }
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-kotlin" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -99,5 +101,5 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
@@ -176,3 +176,8 @@ operator fun PaddingValues.plus(other: PaddingValues) = PaddingValues(
) )
expect val materialYouAvailable: Boolean expect val materialYouAvailable: Boolean
inline fun <T> T.require( message: String, condition: (T) -> Boolean): T {
require(condition(this)) { message }
return this
}