mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement full encryption protocol and haptic events on iOS
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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) {
|
||||
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)
|
||||
}
|
||||
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" }
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -175,4 +175,9 @@ 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
|
||||
}
|
||||
Reference in New Issue
Block a user