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 {
|
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 AES_KEY_SIZE = 32
|
||||||
private const val kCCModeGCM = 11u
|
private const val GCM_IV_SIZE = 12
|
||||||
|
private const val GCM_TAG_SIZE = 16
|
||||||
|
|
||||||
|
@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class)
|
||||||
actual object DmCrypto {
|
actual object DmCrypto {
|
||||||
private const val AES_KEY_SIZE = 32
|
private val provider get() = CryptographyProvider.Default
|
||||||
private const val GCM_IV_SIZE = 12
|
private val aesGcm get() = provider.get(AES.GCM)
|
||||||
private const val GCM_TAG_SIZE = 16
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
actual suspend fun unwrapMek(
|
||||||
actual suspend fun unwrapMek(wrappedMekB64: String, wrappingKey: ByteArray): ByteArray =
|
wrappedMekB64: String,
|
||||||
withContext(Dispatchers.Default) {
|
wrappingKey: ByteArray
|
||||||
require(wrappingKey.size == AES_KEY_SIZE) { "Wrapping key must be 32 bytes" }
|
) = 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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