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 {
baseName = "ComposeApp"
isStatic = true
linkerOpts("-framework", "UIKit")
}
}
@@ -77,12 +78,15 @@ kotlin {
implementation(project(":utils:shared"))
implementation(libs.krypto)
implementation(libs.cryptography.core)
implementation(libs.cryptography.provider.optimal)
}
iosMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
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.HttpClientConfig
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.defaultRequest
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.headers
actual fun createPlatformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient {
@Suppress("UNCHECKED_CAST")
return HttpClient(Darwin) {
// Configure default request headers to ensure UTF-8 encoding
defaultRequest {
contentType(ContentType.Application.Json)
headers {
append("Accept-Charset", "utf-8")
append("Content-Type", "application/json; charset=utf-8")
}
}
// Add timeout configuration
install(HttpTimeout) {
requestTimeoutMillis = 30000
connectTimeoutMillis = 30000
}
// Apply the passed configuration block
block(this)
}
}
@@ -1,29 +1,81 @@
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.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 {
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(
password: String,
bundle: PrivateKeyBundle
): EncryptedBackupBlob = withContext(Dispatchers.Default) {
// iOS backup encryption is not implemented yet; fail fast if called.
error("Backup encryption is not yet implemented on iOS.")
val salt = CryptographyRandom.nextBytes(16)
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(
password: String,
blob: EncryptedBackupBlob
): PrivateKeyBundle = withContext(Dispatchers.Default) {
// iOS backup decryption is not implemented yet; fail fast if called.
error("Backup decryption is not yet implemented on iOS.")
deserializeBundle(
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 {
val bytes = ByteArray(length)
Random.Default.nextBytes(bytes)
return bytes
}
actual fun randomBytes(length: Int): ByteArray = CryptographyRandom.nextBytes(length)
}
@@ -1,79 +1,71 @@
package ru.fromchat.crypto.dm
import com.pr0gramm3r101.utils.crypto.Base64
import com.pr0gramm3r101.utils.crypto.Hmac
import com.pr0gramm3r101.utils.crypto.PasswordHash
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.alloc
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 com.pr0gramm3r101.utils.require
import dev.whyoleg.cryptography.BinarySize.Companion.bits
import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.algorithms.AES
import kotlinx.coroutines.Dispatchers
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
private const val AES_KEY_SIZE = 32
private const val GCM_IV_SIZE = 12
private const val GCM_TAG_SIZE = 16
@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class)
actual object DmCrypto {
private const val AES_KEY_SIZE = 32
private const val GCM_IV_SIZE = 12
private const val GCM_TAG_SIZE = 16
private val provider get() = CryptographyProvider.Default
private val aesGcm get() = provider.get(AES.GCM)
@OptIn(ExperimentalForeignApi::class)
actual suspend fun unwrapMek(wrappedMekB64: String, wrappingKey: ByteArray): ByteArray =
withContext(Dispatchers.Default) {
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)
require(wrapped.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Wrapped MEK too short" }
// Extract IV and ciphertext+tag
val iv = wrapped.sliceArray(0 until GCM_IV_SIZE)
val ciphertext = wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
// Decrypt using AES-GCM
aesGcmDecrypt(wrappingKey, iv, ciphertext)
aesGcmDecrypt(
wrappingKey,
wrapped.sliceArray(0 until GCM_IV_SIZE),
wrapped.sliceArray(GCM_IV_SIZE until wrapped.size)
)
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun decryptEnvelope(
ivB64: String,
ciphertextB64: String,
mek: ByteArray
): ByteArray = withContext(Dispatchers.Default) {
require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
val iv = Base64.decode(ivB64)
val ciphertext = Base64.decode(ciphertextB64)
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
// Decrypt using AES-GCM
aesGcmDecrypt(mek, iv, ciphertext)
) = withContext(Dispatchers.Default) {
aesGcmDecrypt(
mek.require("MEK must be 32 bytes") {
it.size == AES_KEY_SIZE
},
Base64
.decode(ivB64)
.require("IV must be 12 bytes") {
it.size == GCM_IV_SIZE
},
Base64
.decode(ciphertextB64)
.require("Ciphertext too short") {
it.size >= GCM_TAG_SIZE
}
)
}
@OptIn(ExperimentalForeignApi::class)
private fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
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" }
// TODO: Implement proper AES-GCM using CommonCrypto or CryptoKit
// See BackupCrypto.ios.kt for implementation notes.
// For now, delegate to BackupCrypto's implementation when available.
error("AES-GCM decryption not yet implemented for iOS. Use CryptoKit via Swift interop or krypto library.")
return aesGcm
.keyDecoder()
.decodeFromByteArray(
AES.Key.Format.RAW,
key
)
.cipher(tagSize = 128.bits)
.decryptWithIv(iv, ciphertext)
}
}
@@ -1,5 +1,10 @@
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.withContext
@@ -7,8 +12,24 @@ actual object TransportCrypto {
actual suspend fun encryptWithTransportKey(
plaintext: String,
transportPublicKeyB64: String
): TransportCiphertext = withContext(Dispatchers.Default) {
error("TransportCrypto.encryptWithTransportKey is not yet implemented on iOS.")
) = withContext(Dispatchers.Default) {
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
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.cinterop.ExperimentalForeignApi
import platform.UIKit.UIImpactFeedbackGenerator
import platform.UIKit.UIImpactFeedbackStyle
@Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
// iOS has no system back button; back is handled by navigation.
}
@OptIn(ExperimentalForeignApi::class)
@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
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (darkTheme) darkColorScheme()
else lightColorScheme()
if (darkTheme) darkColorScheme() else lightColorScheme()
@@ -1,9 +1,291 @@
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.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
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]
agp = "9.0.0"
androidx-activityCompose = "1.12.2"
androidx-activityCompose = "1.12.3"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.17.0"
coilCompose = "3.3.0"
@@ -11,7 +11,7 @@ coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.1"
googleServices = "4.4.4"
haze = "1.7.1"
kotlin = "2.3.0"
kotlin = "2.3.10"
adaptiveAndroid = "1.2.0"
biometric = "1.4.0-alpha05"
gson = "2.13.2"
@@ -19,19 +19,19 @@ kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2"
multiplatformSettings = "1.3.0"
serialization = "2.3.0"
serialization-json = "1.9.0"
serialization-json = "1.10.0"
material = "1.13.0"
activityKtx = "1.12.2"
activityKtx = "1.12.3"
navigationCompose = "2.9.1"
datastore = "1.2.0"
security-crypto = "1.1.0"
ktor = "3.3.3"
ktor = "3.4.0"
slf4j = "1.7.36"
kotlinxDatetime = "0.7.1"
cryptography-kotlin = "0.5.0"
krypto = "4.0.10"
lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.01.00"
composeBom = "2026.01.01"
composeMaterialIconsExtended = "1.7.3"
composeMaterial3 = "1.10.0-alpha05"
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-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
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]
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-library = { id = "com.android.kotlin.multiplatform.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" }
@@ -176,3 +176,8 @@ operator fun PaddingValues.plus(other: PaddingValues) = PaddingValues(
)
expect val materialYouAvailable: Boolean
inline fun <T> T.require( message: String, condition: (T) -> Boolean): T {
require(condition(this)) { message }
return this
}