Refactor the whole project structure

This commit is contained in:
2026-06-08 21:26:55 +03:00
Unverified
parent 70a9f8635e
commit 712e4a9ed4
364 changed files with 7174 additions and 7335 deletions
+1 -1
View File
@@ -56,7 +56,7 @@
</receiver> </receiver>
<provider <provider
android:name="ru.fromchat.core.files.AttachmentFileProvider" android:name="ru.fromchat.api.local.cache.AttachmentFileProvider"
android:authorities="${applicationId}.attachment_files" android:authorities="${applicationId}.attachment_files"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
@@ -10,8 +10,8 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentTransferBootstrap import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.notifications.NotificationHelper import ru.fromchat.notifications.NotificationHelper
class App: Application() { class App: Application() {
@@ -13,7 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.GoogleApiAvailability
import io.ktor.client.call.body import io.ktor.client.call.body
@@ -25,13 +25,11 @@ import io.ktor.http.contentType
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.MessagesResponse import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.core.config.Config import ru.fromchat.config.ServerConfig
import ru.fromchat.core.Logger
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.isPublicChatVisible import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type" private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id" private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
@@ -172,13 +170,13 @@ class MainActivity : ComponentActivity() {
try { try {
// Get all unread messages and mark them as read // Get all unread messages and mark them as read
val messageIds = ApiClient.http val messageIds = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new") .get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>() .body<MessagesResponse>()
.messages .messages
.map { it.id } .map { it.id }
if (messageIds.isNotEmpty()) { if (messageIds.isNotEmpty()) {
ApiClient.http.post("${Config.apiBaseUrl}/messages/read") { ApiClient.http.post("${ServerConfig.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf("messageIds" to messageIds)) setBody(mapOf("messageIds" to messageIds))
} }
@@ -10,7 +10,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.notifications.NotificationHelper import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() { class FromChatFirebaseMessagingService : FirebaseMessagingService() {
@@ -27,14 +27,15 @@ import kotlinx.coroutines.launch
import ru.fromchat.MainActivity import ru.fromchat.MainActivity
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmHistoryResponse import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.Message import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.MessagesResponse import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
import ru.fromchat.core.config.Config import ru.fromchat.config.ServerConfig
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.api.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.ui.isPublicChatVisible import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
import kotlin.time.Instant
object NotificationHelper { object NotificationHelper {
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type" private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
@@ -141,7 +142,7 @@ object NotificationHelper {
} }
val messages = ApiClient.http val messages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new") .get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>() .body<MessagesResponse>()
.messages .messages
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages") Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages")
@@ -164,7 +165,7 @@ object NotificationHelper {
Log.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying") Log.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
ApiClient.loadPersistedData() ApiClient.loadPersistedData()
val retryMessages = ApiClient.http val retryMessages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new") .get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>() .body<MessagesResponse>()
.messages .messages
Log.d( Log.d(
@@ -470,7 +471,7 @@ object NotificationHelper {
).setConversationTitle("Public Chat").let { ).setConversationTitle("Public Chat").let {
for (msg in messages.takeLast(10)) { for (msg in messages.takeLast(10)) {
val timestamp = try { val timestamp = try {
java.time.Instant.parse(msg.timestamp).toEpochMilli() Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) { } catch (_: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }
+1 -1
View File
@@ -11,7 +11,7 @@ kotlin {
android { android {
namespace = "ru.fromchat.shared" namespace = "ru.fromchat.shared"
minSdk = 24 minSdk = 24
compileSdk = 36 compileSdk = 37
} }
compilerOptions { compilerOptions {
@@ -9,15 +9,15 @@
<application> <application>
<service <service
android:name="ru.fromchat.calls.CallForegroundService" android:name="ru.fromchat.ui.calls.CallForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="camera|microphone" /> android:foregroundServiceType="camera|microphone" />
<service <service
android:name="ru.fromchat.download.AttachmentDownloadForegroundService" android:name="ru.fromchat.api.local.workers.AttachmentDownloadForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service <service
android:name="ru.fromchat.download.AttachmentFileCopyForegroundService" android:name="ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service <service
@@ -1,4 +1,4 @@
package ru.fromchat.core package ru.fromchat
import android.util.Log import android.util.Log
@@ -0,0 +1,6 @@
package ru.fromchat.api
actual suspend fun syncPushTokenAfterStartup() {
uploadPendingFcmTokenIfAvailable()
ensureFcmTokenRegistered()
}
@@ -1,9 +1,9 @@
package ru.fromchat.fcm package ru.fromchat.api
import android.util.Log import android.util.Log
import com.google.android.gms.tasks.Task
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import com.google.android.gms.tasks.Task
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.post import io.ktor.client.request.post
@@ -11,9 +11,8 @@ import io.ktor.client.request.setBody
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.SimpleStatusResponse import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.api.ApiClient import ru.fromchat.config.ServerConfig
import ru.fromchat.core.config.Config
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -37,7 +36,7 @@ private suspend fun postFcmToken(token: String): Boolean {
return runCatching { return runCatching {
val suffix = token.takeLast(8) val suffix = token.takeLast(8)
ApiClient.http ApiClient.http
.post("${Config.apiBaseUrl}/push/register") { .post("${ServerConfig.apiBaseUrl}/push/register") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(ApiClient.json.encodeToString(mapOf("token" to token))) setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
} }
@@ -107,7 +106,7 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc
val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim() val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim()
Log.d("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}") Log.d("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
return@withContext runCatching { return@withContext runCatching {
ApiClient.http.post("${Config.apiBaseUrl}/push/unregister") { ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
header("Content-Type", "application/json") header("Content-Type", "application/json")
if (token.isNotEmpty()) { if (token.isNotEmpty()) {
setBody(ApiClient.json.encodeToString(mapOf("token" to token))) setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
@@ -1,7 +1,11 @@
package ru.fromchat.crypto.backup package ru.fromchat.api.crypto.backup
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.crypto.backup.EncryptedBackupBlob
import ru.fromchat.api.crypto.backup.PrivateKeyBundle
import ru.fromchat.api.crypto.backup.deserializeBundle
import ru.fromchat.api.crypto.backup.serializeBundle
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
@@ -1,10 +1,10 @@
package ru.fromchat.crypto.dm package ru.fromchat.api.crypto.dm
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.api.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.backup.BackupCryptoPlatform import ru.fromchat.api.crypto.backup.BackupCryptoPlatform
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
actual object DmCrypto { actual object DmCrypto {
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.dm package ru.fromchat.api.crypto.dm
import org.bouncycastle.crypto.engines.AESEngine import org.bouncycastle.crypto.engines.AESEngine
import org.bouncycastle.crypto.modes.GCMBlockCipher import org.bouncycastle.crypto.modes.GCMBlockCipher
@@ -6,6 +6,7 @@ import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.params.KeyParameter import org.bouncycastle.crypto.params.KeyParameter
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -28,8 +29,8 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
require(key.size == AES_KEY_SIZE) { "MEK must be 32 bytes" } require(key.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" } require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
val inputFile = java.io.File(encryptedPath) val inputFile = File(encryptedPath)
val outputFile = java.io.File(outputPath) val outputFile = File(outputPath)
outputFile.parentFile?.mkdirs() outputFile.parentFile?.mkdirs()
val encryptedSize = inputFile.length() val encryptedSize = inputFile.length()
@@ -1,8 +1,9 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
import com.iwebpp.crypto.TweetNaclFast import com.iwebpp.crypto.TweetNaclFast
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.crypto.transport.TransportCiphertext
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Base64 import java.util.Base64
@@ -1,9 +1,10 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.crypto.transport.encryptPlaintextFileToFcaeBlob
actual object TransportFileEncryptor { actual object TransportFileEncryptor {
actual suspend fun encryptPlaintextFileToTransportBlob( actual suspend fun encryptPlaintextFileToTransportBlob(
@@ -1,12 +1,13 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
import com.ionspin.kotlin.crypto.LibsodiumInitializer import com.ionspin.kotlin.crypto.LibsodiumInitializer
import com.ionspin.kotlin.crypto.box.Box import com.ionspin.kotlin.crypto.box.Box
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import ru.fromchat.crypto.backup.BackupCryptoPlatform import ru.fromchat.api.crypto.backup.BackupCryptoPlatform
import javax.crypto.Mac import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
@OptIn(ExperimentalUnsignedTypes::class)
internal actual fun deriveTransportFileAesKey( internal actual fun deriveTransportFileAesKey(
transportPublicKeyB64: String, transportPublicKeyB64: String,
ephemeralSecretKey: ByteArray, ephemeralSecretKey: ByteArray,
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.cache
import android.content.Context import android.content.Context
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -8,6 +8,8 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import ru.fromchat.api.local.download.cachedAttachmentFileSize
import ru.fromchat.ui.chat.copyCachedFileToDestinationUri
class AttachmentFileCopyWorker( class AttachmentFileCopyWorker(
appContext: Context, appContext: Context,
@@ -53,7 +55,3 @@ class AttachmentFileCopyWorker(
} }
} }
} }
internal actual fun enqueuePlatformCopy(storageKey: String) {
AttachmentFileCopyWorker.enqueue(storageKey)
}
@@ -1,4 +1,4 @@
package ru.fromchat.core.files package ru.fromchat.api.local.cache
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
@@ -85,14 +85,14 @@ class AttachmentFileProvider : FileProvider() {
companion object { companion object {
fun uriForFile(context: Context, file: File): Uri? = runCatching { fun uriForFile(context: Context, file: File): Uri? = runCatching {
FileProvider.getUriForFile( getUriForFile(
context, context,
"${context.packageName}.attachment_files", "${context.packageName}.attachment_files",
file, file,
) )
}.getOrNull() }.getOrNull()
/** Strips cache storage-key prefix from on-disk basename (see [ru.fromchat.ui.chat.DecryptedFileCache]). */ /** Strips cache storage-key prefix from on-disk basename (see [DecryptedFileCache]). */
internal fun displayNameFor(file: File): String = internal fun displayNameFor(file: File): String =
displayNameFromBasename(file.name) displayNameFromBasename(file.name)
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
private const val MIN_BYTES = 512L * 1024L private const val MIN_BYTES = 512L * 1024L
private const val MAX_BYTES = 48L * 1024L * 1024L private const val MAX_BYTES = 48L * 1024L * 1024L
@@ -1,4 +1,4 @@
package ru.fromchat.core.files package ru.fromchat.api.local.cache
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import java.io.File import java.io.File
@@ -1,11 +1,10 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.db.MessageDatabaseProvider import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.core.cache.wipeFromChatCacheDirectory import java.io.File
private const val GENERATION_FILE = ".generation" private const val GENERATION_FILE = ".generation"
@@ -0,0 +1,5 @@
package ru.fromchat.api.local.cache
internal actual fun enqueuePlatformCopy(storageKey: String) {
AttachmentFileCopyWorker.enqueue(storageKey)
}
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
import android.net.Uri import android.net.Uri
import android.content.res.AssetFileDescriptor import android.content.res.AssetFileDescriptor
@@ -7,6 +7,7 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream
private fun uploadDir(instanceId: String): File { private fun uploadDir(instanceId: String): File {
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
@@ -202,7 +203,7 @@ actual suspend fun repairInterruptedUploadArtifacts(
} }
private class AndroidOutboundFileInputStream( private class AndroidOutboundFileInputStream(
private val input: java.io.InputStream, private val input: InputStream,
) : OutboundFileInputStream { ) : OutboundFileInputStream {
override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int = override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -1,4 +1,4 @@
package ru.fromchat.api.db package ru.fromchat.api.local.db
private val lock = Any() private val lock = Any()
@@ -1,11 +1,11 @@
package ru.fromchat.api.db package ru.fromchat.api.local.db.store
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import androidx.sqlite.db.SupportSQLiteDatabase
import java.io.File
import ru.fromchat.db.MessageDatabase import ru.fromchat.db.MessageDatabase
import java.io.File
actual fun provideMessageDatabaseDriver(): SqlDriver { actual fun provideMessageDatabaseDriver(): SqlDriver {
val context = UtilsLibrary.context val context = UtilsLibrary.context
@@ -30,7 +30,7 @@ private fun removeLegacyDatabaseFiles(legacyDb: File) {
} }
/** /**
* SQLDelight [user_version] is not used for migrations; [ensureMessageDatabaseSchema] diffs structure. * SQLDelight [user_version] is not used for migrations; [ru.fromchat.api.local.db.ensureMessageDatabaseSchema] diffs structure.
*/ */
private class DiffOnlyDatabaseCallback : AndroidSqliteDriver.Callback(MessageDatabase.Schema) { private class DiffOnlyDatabaseCallback : AndroidSqliteDriver.Callback(MessageDatabase.Schema) {
override fun onCreate(db: SupportSQLiteDatabase) { override fun onCreate(db: SupportSQLiteDatabase) {
@@ -1,7 +1,7 @@
package ru.fromchat.api package ru.fromchat.api.local.download
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import ru.fromchat.download.AttachmentDownloadForegroundService import ru.fromchat.api.local.workers.AttachmentDownloadForegroundService
actual object AttachmentDownloadForeground { actual object AttachmentDownloadForeground {
actual fun onFileDownloadStarted(storageKey: String) { actual fun onFileDownloadStarted(storageKey: String) {
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.download
import android.app.Activity import android.app.Activity
import android.content.ClipData import android.content.ClipData
@@ -10,8 +10,10 @@ import android.os.Build
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.core.Logger import ru.fromchat.Logger
import ru.fromchat.core.files.AttachmentFileProvider import ru.fromchat.api.local.cache.AttachmentFileProvider
import ru.fromchat.api.local.mimeTypeForFilename
import ru.fromchat.ui.chat.uriToLocalCacheFile
actual suspend fun openCachedAttachmentFile( actual suspend fun openCachedAttachmentFile(
cacheUri: String, cacheUri: String,
@@ -1,4 +1,4 @@
package ru.fromchat.api package ru.fromchat.api.local.download
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.download
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -6,6 +6,7 @@ import android.graphics.Matrix
import android.util.LruCache import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
actual object PlatformDecodedBitmapCache { actual object PlatformDecodedBitmapCache {
@@ -46,46 +47,77 @@ actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int)
private fun decodeSampledFromFile(path: String, reqWidthPx: Int, reqHeightPx: Int): Bitmap? { private fun decodeSampledFromFile(path: String, reqWidthPx: Int, reqHeightPx: Int): Bitmap? {
val orientation = readExifOrientation(path) val orientation = readExifOrientation(path)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds) BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation) val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation)
val sampleSize = calculateInSampleSize(orientedW, orientedH, reqWidthPx, reqHeightPx)
val options = BitmapFactory.Options().apply { return scaleBitmapToFitWithin(
inSampleSize = sampleSize applyExifOrientation(
BitmapFactory.decodeFile(
path,
BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(
orientedW,
orientedH,
reqWidthPx,
reqHeightPx
)
inPreferredConfig = Bitmap.Config.ARGB_8888 inPreferredConfig = Bitmap.Config.ARGB_8888
} }
val decoded = BitmapFactory.decodeFile(path, options) ?: return null ) ?: return null,
val oriented = applyExifOrientation(decoded, orientation) orientation
return scaleBitmapToFitWithin(oriented, reqWidthPx, reqHeightPx) ),
reqWidthPx,
reqHeightPx
)
} }
private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): Bitmap? { private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val sampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, reqWidthPx, reqHeightPx)
val options = BitmapFactory.Options().apply { return scaleBitmapToFitWithin(
inSampleSize = sampleSize BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(
bounds.outWidth,
bounds.outHeight,
reqWidthPx,
reqHeightPx
)
inPreferredConfig = Bitmap.Config.ARGB_8888 inPreferredConfig = Bitmap.Config.ARGB_8888
} }
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null ) ?: return null,
return scaleBitmapToFitWithin(decoded, reqWidthPx, reqHeightPx) reqWidthPx,
reqHeightPx
)
} }
/** Downscale only — never upscale or crop; [ContentScale.Crop] on the tile handles fill. */
private fun scaleBitmapToFitWithin(bitmap: Bitmap, reqWidthPx: Int, reqHeightPx: Int): Bitmap { private fun scaleBitmapToFitWithin(bitmap: Bitmap, reqWidthPx: Int, reqHeightPx: Int): Bitmap {
if (bitmap.width <= reqWidthPx && bitmap.height <= reqHeightPx) return bitmap if (bitmap.width <= reqWidthPx && bitmap.height <= reqHeightPx) return bitmap
val scale = minOf( val scale = minOf(
reqWidthPx.toFloat() / bitmap.width.toFloat(), reqWidthPx.toFloat() / bitmap.width.toFloat(),
reqHeightPx.toFloat() / bitmap.height.toFloat(), reqHeightPx.toFloat() / bitmap.height.toFloat(),
) )
if (scale >= 1f) return bitmap if (scale >= 1f) return bitmap
val dstW = (bitmap.width * scale).toInt().coerceAtLeast(1)
val dstH = (bitmap.height * scale).toInt().coerceAtLeast(1) return bitmap.scale(
return Bitmap.createScaledBitmap(bitmap, dstW, dstH, true) (bitmap.width * scale).toInt().coerceAtLeast(1),
(bitmap.height * scale).toInt().coerceAtLeast(1)
)
} }
private fun readExifOrientation(path: String): Int = private fun readExifOrientation(path: String) =
runCatching { runCatching {
ExifInterface(path).getAttributeInt( ExifInterface(path).getAttributeInt(
ExifInterface.TAG_ORIENTATION, ExifInterface.TAG_ORIENTATION,
@@ -98,13 +130,13 @@ private fun orientedDimensions(width: Int, height: Int, orientation: Int): Pair<
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270, ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_TRANSVERSE -> height to width
-> height to width
else -> width to height else -> width to height
} }
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap { private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix() val matrix = Matrix()
when (orientation) { when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
@@ -121,8 +153,16 @@ private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
} }
else -> return bitmap else -> return bitmap
} }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
?: bitmap return Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
} }
/** Android docs: largest inSampleSize where both dimensions stay >= requested. */ /** Android docs: largest inSampleSize where both dimensions stay >= requested. */
@@ -133,12 +173,15 @@ private fun calculateInSampleSize(
reqHeightPx: Int, reqHeightPx: Int,
): Int { ): Int {
var inSampleSize = 1 var inSampleSize = 1
if (height > reqHeightPx || width > reqWidthPx) { if (height > reqHeightPx || width > reqWidthPx) {
var halfHeight = height / 2 val halfHeight = height / 2
var halfWidth = width / 2 val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeightPx && halfWidth / inSampleSize >= reqWidthPx) { while (halfHeight / inSampleSize >= reqHeightPx && halfWidth / inSampleSize >= reqWidthPx) {
inSampleSize *= 2 inSampleSize *= 2
} }
} }
return inSampleSize.coerceAtLeast(1) return inSampleSize.coerceAtLeast(1)
} }
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.download
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@@ -16,6 +16,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.local.cache.PendingFileSaveEntry
import ru.fromchat.api.local.cache.PendingFileSaveRegistry
import ru.fromchat.ui.chat.copyCachedFileToDestinationUri
private class CreateFileSaveContract : ActivityResultContract<SavableMessageFile, Uri?>() { private class CreateFileSaveContract : ActivityResultContract<SavableMessageFile, Uri?>() {
override fun createIntent(context: Context, input: SavableMessageFile): Intent { override fun createIntent(context: Context, input: SavableMessageFile): Intent {
@@ -54,17 +57,21 @@ actual fun rememberPlatformSaveMessageFile(
val launcher = rememberLauncherForActivityResult(CreateFileSaveContract()) { destination -> val launcher = rememberLauncherForActivityResult(CreateFileSaveContract()) { destination ->
val pending = pendingSavable val pending = pendingSavable
pendingSavable = null pendingSavable = null
if (destination == null || pending == null) { if (destination == null || pending == null) {
onComplete(false) onComplete(false)
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
scope.launch { scope.launch {
runCatching { runCatching {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION UtilsLibrary.context.contentResolver.takePersistableUriPermission(
UtilsLibrary.context.contentResolver.takePersistableUriPermission(destination, flags) destination,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} }
val cacheUri = pending.cacheUri
if (cachedAttachmentFileSize(cacheUri) <= 0L) { if (cachedAttachmentFileSize(pending.cacheUri) <= 0L) {
PendingFileSaveRegistry.schedule( PendingFileSaveRegistry.schedule(
PendingFileSaveEntry( PendingFileSaveEntry(
storageKey = pending.storageKey, storageKey = pending.storageKey,
@@ -77,12 +84,14 @@ actual fun rememberPlatformSaveMessageFile(
onComplete(false) onComplete(false)
return@launch return@launch
} }
val ok = copyCachedFileToDestinationUri( val ok = copyCachedFileToDestinationUri(
sourceCacheUri = cacheUri, sourceCacheUri = pending.cacheUri,
destinationUri = destination.toString(), destinationUri = destination.toString(),
storageKey = pending.storageKey, storageKey = pending.storageKey,
displayFilename = pending.filename, displayFilename = pending.filename,
) )
if (ok) { if (ok) {
PendingFileSaveRegistry.remove(pending.storageKey) PendingFileSaveRegistry.remove(pending.storageKey)
} else { } else {
@@ -96,9 +105,11 @@ actual fun rememberPlatformSaveMessageFile(
), ),
) )
} }
onComplete(ok) onComplete(ok)
} }
} }
return remember(launcher) { return remember(launcher) {
{ savable: SavableMessageFile -> { savable: SavableMessageFile ->
pendingSavable = savable pendingSavable = savable
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.download
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@@ -59,15 +59,17 @@ actual fun rememberPlatformSaveMessageImage(
onComplete(false) onComplete(false)
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
scope.launch { scope.launch {
val ok = withContext(Dispatchers.IO) { onComplete(
withContext(Dispatchers.IO) {
runCatching { runCatching {
UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out -> UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out ->
out.write(bytes) out.write(bytes)
} != null } != null
}.getOrDefault(false) }.getOrDefault(false)
} }
onComplete(ok) )
} }
} }
@@ -1,5 +1,6 @@
package ru.fromchat.api.outbox package ru.fromchat.api.local.send
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -47,7 +48,7 @@ object MediaUploadForegroundHelper {
val builder = NotificationCompat.Builder(context, CHANNEL_ID) val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setContentText(contentText) .setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_upload) .setSmallIcon(R.drawable.stat_sys_upload)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -1,4 +1,4 @@
package ru.fromchat.api.outbox package ru.fromchat.api.local.send
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data import androidx.work.Data
@@ -1,13 +1,13 @@
package ru.fromchat.api.outbox package ru.fromchat.api.local.send
import android.content.Context import android.content.Context
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.AttachmentUploadNotifier import ru.fromchat.api.local.workers.AttachmentUploadNotifier
import ru.fromchat.api.AttachmentUploadProgress import ru.fromchat.api.local.workers.AttachmentUploadProgress
import ru.fromchat.core.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
class OutboxSendWorker( class OutboxSendWorker(
context: Context, context: Context,
@@ -1,5 +1,6 @@
package ru.fromchat.download package ru.fromchat.api.local.workers
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -104,7 +105,7 @@ class AttachmentDownloadForegroundService : Service() {
val builder = NotificationCompat.Builder(this, CHANNEL_ID) val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setContentText(contentText) .setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(R.drawable.stat_sys_download)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -127,9 +128,9 @@ class AttachmentDownloadForegroundService : Service() {
private const val CHANNEL_ID = "fromchat_file_download" private const val CHANNEL_ID = "fromchat_file_download"
private const val NOTIFICATION_ID = 0xFC12 private const val NOTIFICATION_ID = 0xFC12
private const val ACTION_START = "ru.fromchat.download.AttachmentDownloadForegroundService.START" private const val ACTION_START = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.START"
private const val ACTION_STOP = "ru.fromchat.download.AttachmentDownloadForegroundService.STOP" private const val ACTION_STOP = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.STOP"
private const val ACTION_UPDATE = "ru.fromchat.download.AttachmentDownloadForegroundService.UPDATE" private const val ACTION_UPDATE = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.UPDATE"
private const val EXTRA_CHANNEL_NAME = "channel_name" private const val EXTRA_CHANNEL_NAME = "channel_name"
private const val EXTRA_TITLE = "title" private const val EXTRA_TITLE = "title"
@@ -1,7 +1,6 @@
package ru.fromchat.api package ru.fromchat.api.local.workers
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import ru.fromchat.download.AttachmentFileCopyForegroundService
actual object AttachmentFileCopyForeground { actual object AttachmentFileCopyForeground {
actual fun onCopyStarted(storageKey: String, displayLabel: String?) { actual fun onCopyStarted(storageKey: String, displayLabel: String?) {
@@ -1,5 +1,6 @@
package ru.fromchat.download package ru.fromchat.api.local.workers
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -51,7 +52,7 @@ class AttachmentFileCopyForegroundService : Service() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setContentText(contentText) .setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(R.drawable.stat_sys_download_done)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
@@ -81,9 +82,9 @@ class AttachmentFileCopyForegroundService : Service() {
private const val NOTIFICATION_ID = 0xFC13 private const val NOTIFICATION_ID = 0xFC13
private const val ACTION_START = private const val ACTION_START =
"ru.fromchat.download.AttachmentFileCopyForegroundService.START" "ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService.START"
private const val ACTION_STOP = private const val ACTION_STOP =
"ru.fromchat.download.AttachmentFileCopyForegroundService.STOP" "ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService.STOP"
private const val EXTRA_CHANNEL_NAME = "channel_name" private const val EXTRA_CHANNEL_NAME = "channel_name"
private const val EXTRA_TITLE = "title" private const val EXTRA_TITLE = "title"
@@ -1,11 +1,12 @@
package ru.fromchat.api package ru.fromchat.api.local.workers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.outbox.OutgoingMessageCoordinator import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.outbox.scheduleOutboxProcessing import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.core.cache.CacheContext import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext
actual object AttachmentUploadQueue { actual object AttachmentUploadQueue {
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
@@ -18,7 +19,7 @@ actual object AttachmentUploadQueue {
val instanceId = CacheContext.activeInstanceId.value.trim() val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isEmpty()) return if (instanceId.isEmpty()) return
MainScope().launch { MainScope().launch {
val row = ru.fromchat.api.db.MessageDatabaseProvider.database.messageDatabaseQueries val row = MessageDatabaseProvider.database.messageDatabaseQueries
.selectOutboxItem(instanceId, jobId.trim()) .selectOutboxItem(instanceId, jobId.trim())
.executeAsOneOrNull() .executeAsOneOrNull()
val conversationId = row?.conversationId ?: return@launch val conversationId = row?.conversationId ?: return@launch
@@ -1,9 +0,0 @@
package ru.fromchat.core
import ru.fromchat.fcm.ensureFcmTokenRegistered
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
actual suspend fun syncPushTokenAfterStartup() {
uploadPendingFcmTokenIfAvailable()
ensureFcmTokenRegistered()
}
@@ -1,55 +0,0 @@
package ru.fromchat.debug
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
actual object DebugLogger {
private const val ENDPOINT =
"http://127.0.0.1:7809/ingest/d9aebf8d-fb01-41c9-af88-eb0676507989"
private const val SESSION_ID = "9be525"
// Single shared Ktor client for all debug logs.
private val client = HttpClient(OkHttp)
// Lightweight scope for fire-and-forget debug logging.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
actual fun log(payload: DebugLogPayload) {
// Network logging is best-effort; never throw from here.
val safeMessage = payload.message.replace("\"", "'")
val safeLocation = payload.location.replace("\"", "'")
val json = buildString {
append('{')
append("\"sessionId\":\"").append(payload.sessionId).append('"')
append(",\"runId\":\"").append(payload.runId).append('"')
append(",\"hypothesisId\":\"").append(payload.hypothesisId).append('"')
append(",\"location\":\"").append(safeLocation).append('"')
append(",\"message\":\"").append(safeMessage).append('"')
append(",\"data\":").append(payload.data)
append(",\"timestamp\":").append(payload.timestamp)
append('}')
}
scope.launch {
try {
client.post(ENDPOINT) {
contentType(ContentType.Application.Json)
header("X-Debug-Session-Id", SESSION_ID)
setBody(json)
}
} catch (_: Exception) {
// Swallow all errors in debug logger.
}
}
}
}
@@ -1,22 +0,0 @@
package ru.fromchat.platform
import android.os.Build
private fun String?.trimIfNotBlank(): String? =
this?.trim()?.takeIf { it.isNotEmpty() }
actual fun currentDeviceInfo(): CurrentDeviceInfo {
val osName = "Android"
val osVersion = Build.VERSION.RELEASE.trimIfNotBlank() ?: Build.VERSION.SDK_INT.toString()
val brand = Build.BRAND.trimIfNotBlank() ?: Build.MANUFACTURER.trimIfNotBlank()
val model = Build.MODEL.trimIfNotBlank()
return CurrentDeviceInfo(
osName = osName,
osVersion = osVersion,
deviceType = "mobile",
deviceName = model?.let { if (it.contains(brand.orEmpty(), ignoreCase = true)) it else listOfNotNull(brand, it).joinToString(" ") },
brand = brand,
model = model
)
}
@@ -1,19 +1,21 @@
package ru.fromchat.calls package ru.fromchat.ui.calls
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.graphics.Color
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.Color
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore
/** /**
* Foreground call session: keeps camera / mic eligible in background. * Foreground call session: keeps camera / mic eligible in background.
@@ -34,7 +36,7 @@ class CallForegroundService : Service() {
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_START -> startAsForeground(intent!!) ACTION_START -> startAsForeground(intent)
ACTION_STOP -> { ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@@ -61,7 +63,7 @@ class CallForegroundService : Service() {
val smallIcon = try { val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) { } catch (_: Exception) {
android.R.drawable.sym_call_outgoing R.drawable.sym_call_outgoing
} }
val hangUpPi = PendingIntent.getService( val hangUpPi = PendingIntent.getService(
@@ -1,6 +1,7 @@
package ru.fromchat.calls package ru.fromchat.ui.calls
import android.Manifest import android.Manifest
import android.R
import android.app.Activity import android.app.Activity
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -19,8 +20,8 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
@@ -40,13 +41,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -64,25 +63,24 @@ import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -91,8 +89,10 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -100,21 +100,19 @@ import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import io.livekit.android.RoomOptions import io.livekit.android.RoomOptions
import io.livekit.android.compose.local.RoomScope import io.livekit.android.compose.local.RoomScope
import io.livekit.android.events.RoomEvent
import io.livekit.android.events.collect
import io.livekit.android.compose.state.rememberParticipantTrackReferences import io.livekit.android.compose.state.rememberParticipantTrackReferences
import io.livekit.android.compose.state.rememberParticipants import io.livekit.android.compose.state.rememberParticipants
import io.livekit.android.compose.types.TrackReference import io.livekit.android.compose.types.TrackReference
import io.livekit.android.compose.ui.ScaleType import io.livekit.android.compose.ui.ScaleType
import io.livekit.android.compose.ui.VideoTrackView import io.livekit.android.compose.ui.VideoTrackView
import io.livekit.android.events.RoomEvent
import io.livekit.android.events.collect
import io.livekit.android.room.Room import io.livekit.android.room.Room
import io.livekit.android.room.track.LocalAudioTrackOptions
import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.LocalParticipant
import io.livekit.android.room.participant.Participant import io.livekit.android.room.participant.Participant
import io.livekit.android.room.track.LocalAudioTrackOptions
import io.livekit.android.room.track.Track import io.livekit.android.room.track.Track
import io.livekit.android.room.track.screencapture.ScreenCaptureParams import io.livekit.android.room.track.screencapture.ScreenCaptureParams
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -123,14 +121,29 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.calls.CallStore
import ru.fromchat.core.Logger import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail
import ru.fromchat.cd_call_camera
import ru.fromchat.cd_call_end
import ru.fromchat.cd_call_mic
import ru.fromchat.cd_call_screenshare
import ru.fromchat.Logger
import ru.fromchat.message_sender_you
import ru.fromchat.notif_call_channel_name
import ru.fromchat.notif_call_ongoing_text
import ru.fromchat.notif_call_ongoing_title
import ru.fromchat.notif_screenshare_text
import ru.fromchat.notif_screenshare_title
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import kotlin.math.roundToInt
import kotlin.math.sqrt
private const val TAG = "CallMediaLayer" private const val TAG = "CallMediaLayer"
private const val SCREEN_SHARE_NOTIFICATION_ID = 99102 private const val SCREEN_SHARE_NOTIFICATION_ID = 99102
@@ -458,10 +471,9 @@ actual fun CallMediaLayer(
} }
is RoomEvent.FailedToConnect -> { is RoomEvent.FailedToConnect -> {
val msg = val msg =
event.error?.message event.error.message?.takeIf { it.isNotBlank() }
?.takeIf { it.isNotBlank() } ?: event.error.toString()
?: event.error?.toString()
.orEmpty()
Logger.e(TAG, "RoomEvent.FailedToConnect", event.error) Logger.e(TAG, "RoomEvent.FailedToConnect", event.error)
scheduleReconnect(msg.takeIf { it.isNotBlank() }) scheduleReconnect(msg.takeIf { it.isNotBlank() })
} }
@@ -1159,7 +1171,7 @@ private fun CallPreviewCluster(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
private fun PreviewTile( private fun PreviewTile(
room: Room, room: Room,
@@ -1232,7 +1244,7 @@ private fun CallInlineControlBar(
room: Room, room: Room,
micRequestedOn: Boolean, micRequestedOn: Boolean,
onMicRequestedChange: (Boolean) -> Unit, onMicRequestedChange: (Boolean) -> Unit,
hazeState: dev.chrisbanes.haze.HazeState, hazeState: HazeState,
modifier: Modifier, modifier: Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -1277,7 +1289,7 @@ private fun CallInlineControlBar(
val smallIcon = try { val smallIcon = try {
context.packageManager.getApplicationInfo(context.packageName, 0).icon context.packageManager.getApplicationInfo(context.packageName, 0).icon
} catch (_: Exception) { } catch (_: Exception) {
android.R.drawable.stat_sys_upload R.drawable.stat_sys_upload
} }
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID) return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle) .setContentTitle(updatedTitle)
@@ -1,3 +1,3 @@
package ru.fromchat.calls package ru.fromchat.ui.calls
actual val UseInlineInCallChrome: Boolean = true actual val UseInlineInCallChrome: Boolean = true
@@ -4,7 +4,7 @@ import android.net.Uri
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.AttachmentFileCopyForeground import ru.fromchat.api.local.workers.AttachmentFileCopyForeground
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@@ -1,150 +0,0 @@
package ru.fromchat.ui.chat
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.OpenableColumns
import androidx.exifinterface.media.ExifInterface
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
actual fun getFilenameFromUri(uri: String): String {
val context = com.pr0gramm3r101.utils.UtilsLibrary.context
context.contentResolver.query(Uri.parse(uri), null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
val name = cursor.getString(nameIndex)
if (!name.isNullOrBlank()) return name
}
}
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
}
actual suspend fun getImageAspectRatio(uri: String): Float? {
val context = com.pr0gramm3r101.utils.UtilsLibrary.context
val parsed = Uri.parse(uri)
val orientation = when {
parsed.scheme == "content" || parsed.scheme == "file" -> {
runCatching {
context.contentResolver.openInputStream(parsed)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
}.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
}
else -> {
val path = parsed.path
if (path != null) {
runCatching {
ExifInterface(path).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
} else {
ExifInterface.ORIENTATION_NORMAL
}
}
}
context.contentResolver.openInputStream(parsed)?.use { stream ->
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(stream, null, options)
var w = options.outWidth
var h = options.outHeight
if (w <= 0 || h <= 0) return null
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE,
-> {
val swap = w
w = h
h = swap
}
}
return w.toFloat() / h.toFloat()
}
return null
}
actual suspend fun getImageDimensions(uri: String): Pair<Int, Int>? {
val context = com.pr0gramm3r101.utils.UtilsLibrary.context
val parsed = Uri.parse(uri)
val orientation = when {
parsed.scheme == "content" || parsed.scheme == "file" -> {
runCatching {
context.contentResolver.openInputStream(parsed)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
}.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
}
else -> {
val path = parsed.path
if (path != null) {
runCatching {
ExifInterface(path).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
} else {
ExifInterface.ORIENTATION_NORMAL
}
}
}
context.contentResolver.openInputStream(parsed)?.use { stream ->
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(stream, null, options)
var w = options.outWidth
var h = options.outHeight
if (w <= 0 || h <= 0) return null
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE,
-> {
val swap = w
w = h
h = swap
}
}
return w to h
}
return null
}
@Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris: List<Uri> ->
onResult(uris.map { it.toString() })
}
return {
launcher.launch(
PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly)
.build()
)
}
}
@Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
onResult(uris.map { it.toString() })
}
return {
launcher.launch(arrayOf("*/*"))
}
}
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat.utils
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat.utils
import android.widget.Toast import android.widget.Toast
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
@@ -0,0 +1,118 @@
package ru.fromchat.ui.chat.utils
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import com.pr0gramm3r101.utils.UtilsLibrary
private fun orientation(uri: Uri) = when {
uri.scheme in arrayOf("content", "file") -> {
runCatching {
UtilsLibrary.context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
}.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
}
else -> {
uri.path?.let {
runCatching {
ExifInterface(it).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrNull()
} ?: ExifInterface.ORIENTATION_NORMAL
}
}
private fun floatDimensions(uri: Uri): Pair<Float, Float>? {
UtilsLibrary.context.contentResolver.openInputStream(uri)?.use { stream ->
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(stream, null, options)
var w = options.outWidth
var h = options.outHeight
if (w <= 0 || h <= 0) return null
when (orientation(uri)) {
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE -> {
val swap = w
w = h
h = swap
}
}
return w.toFloat() to h.toFloat()
}
return null
}
actual fun getFilenameFromUri(uri: String): String {
UtilsLibrary
.context
.contentResolver
.query(
uri.toUri(),
null,
null,
null,
null
)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
val name = cursor.getString(nameIndex)
if (!name.isNullOrBlank()) return name
}
}
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
}
actual suspend fun getImageAspectRatio(uri: String) =
floatDimensions(uri.toUri())?.let { it.first / it.second }
actual suspend fun getImageDimensions(uri: String) =
floatDimensions(uri.toUri())?.let { it.first.toInt() to it.second.toInt() }
@Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris: List<Uri> ->
onResult(uris.map { it.toString() })
}
return {
launcher.launch(
PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly)
.build()
)
}
}
@Composable
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
onResult(uris.map { it.toString() })
}
return {
launcher.launch(arrayOf("*/*"))
}
}
@@ -1,16 +1,44 @@
package ru.fromchat.ui package ru.fromchat.ui.components
import android.annotation.SuppressLint
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.activity.compose.BackHandler as AndroidBackHandler import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import ru.fromchat.utils.haptic.HapticFeedbackEvent
import androidx.activity.compose.BackHandler as AndroidBackHandler
@Composable
actual fun PredictiveBackHandler(
enabled: Boolean,
onProgress: (Float) -> Unit,
onCommit: () -> Unit,
onCancel: () -> Unit,
) {
PredictiveBackHandler(enabled = enabled) { progressFlow: Flow<BackEventCompat> ->
try {
progressFlow.collect { backEvent ->
onProgress(backEvent.progress.coerceIn(0f, 1f))
}
onCommit()
} catch (e: CancellationException) {
onCancel()
throw e
}
}
}
@Composable @Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
AndroidBackHandler(enabled = enabled, onBack = onBack) AndroidBackHandler(enabled = enabled, onBack = onBack)
} }
@SuppressLint("InlinedApi")
@Composable @Composable
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit { actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
val view = LocalView.current val view = LocalView.current
@@ -1,8 +0,0 @@
package ru.fromchat.ui.debug
import androidx.compose.runtime.Composable
@Composable
actual fun DebugApiScreen() {
DebugApiScreenContent()
}
@@ -1,40 +1,40 @@
package ru.fromchat.platform package ru.fromchat.ui.main.settings
import android.content.Intent
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import android.provider.Settings import com.pr0gramm3r101.utils.UtilsLibrary
import com.pr0gramm3r101.utils.UtilsLibrary.context
actual fun openAppNotificationSettings(): Boolean = actual fun openAppNotificationSettings(): Boolean =
try { try {
val intent = val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) putExtra(Settings.EXTRA_APP_PACKAGE, UtilsLibrary.context.packageName)
} }
} else { } else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null) data = Uri.fromParts("package", UtilsLibrary.context.packageName, null)
} }
} }
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent) UtilsLibrary.context.startActivity(intent)
true true
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
actual fun areAppNotificationsEnabled(): Boolean { actual fun areAppNotificationsEnabled(): Boolean {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return false if (!NotificationManagerCompat.from(UtilsLibrary.context).areNotificationsEnabled()) return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
context, UtilsLibrary.context,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} else { } else {
@@ -1,28 +0,0 @@
package ru.fromchat.ui.main.settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.runtime.Composable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
@Composable
actual fun SettingsSecurityPredictiveBackHandler(
enabled: Boolean,
onProgress: (Float) -> Unit,
onCommit: () -> Unit,
onCancel: () -> Unit,
) {
PredictiveBackHandler(enabled = enabled) { progressFlow: Flow<BackEventCompat> ->
try {
progressFlow.collect { backEvent ->
onProgress(backEvent.progress.coerceIn(0f, 1f))
}
onCommit()
} catch (e: CancellationException) {
onCancel()
throw e
}
}
}
@@ -1,4 +1,4 @@
package ru.fromchat package ru.fromchat.ui.profile
import android.widget.Toast import android.widget.Toast
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
@@ -6,4 +6,3 @@ import com.pr0gramm3r101.utils.UtilsLibrary
actual fun showProfileLoadErrorMessage(message: String) { actual fun showProfileLoadErrorMessage(message: String) {
Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show() Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show()
} }
@@ -1,15 +1,17 @@
package ru.fromchat.net package ru.fromchat.utils
import android.Manifest
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import androidx.annotation.RequiresPermission
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.local.WebSocketManager
actual object NetworkConnectivity { actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true) private val _isOnline = MutableStateFlow(true)
@@ -17,6 +19,7 @@ actual object NetworkConnectivity {
private var callback: ConnectivityManager.NetworkCallback? = null private var callback: ConnectivityManager.NetworkCallback? = null
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun computeOnline(cm: ConnectivityManager): Boolean { private fun computeOnline(cm: ConnectivityManager): Boolean {
val n = cm.activeNetwork ?: return false val n = cm.activeNetwork ?: return false
@@ -24,6 +27,7 @@ actual object NetworkConnectivity {
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} }
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
actual fun ensureStarted() { actual fun ensureStarted() {
if (callback != null) return if (callback != null) return
val context = UtilsLibrary.context val context = UtilsLibrary.context
@@ -213,12 +213,11 @@
<string name="settings_notifications_title">Уведомления</string> <string name="settings_notifications_title">Уведомления</string>
<string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</string> <string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</string>
<string name="settings_open_notification_settings">Открыть настройки уведомлений</string> <string name="settings_notification_settings">Настройки уведомлений</string>
<string name="settings_notifications_enable">Включить push-уведомления</string> <string name="settings_notification_settings_d">Здесь вы можете включить или выключить типы уведомлений, изменить звук и т.д.</string>
<string name="settings_notifications_disable">Отключить push-уведомления</string>
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string> <string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
<string name="settings_push_notifications_enabled">Push-уведомления включены</string> <string name="settings_push_notifications">Push-уведомления</string>
<string name="settings_push_notifications_disabled">Push-уведомления отключены</string> <string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
<string name="settings_devices_title">Устройства</string> <string name="settings_devices_title">Устройства</string>
<string name="settings_devices_empty">Нет активных сессий</string> <string name="settings_devices_empty">Нет активных сессий</string>
@@ -245,6 +244,8 @@
<string name="settings_devices_logout_all">Выйти на всех других устройствах</string> <string name="settings_devices_logout_all">Выйти на всех других устройствах</string>
<string name="settings_devices_logout_all_confirm_title">Выйти везде, кроме этого?</string> <string name="settings_devices_logout_all_confirm_title">Выйти везде, кроме этого?</string>
<string name="settings_devices_logout_all_confirm_body">Вы останетесь в аккаунте только на этом устройстве.</string> <string name="settings_devices_logout_all_confirm_body">Вы останетесь в аккаунте только на этом устройстве.</string>
<string name="settings_devices_active_sessions">Активные сеансы</string>
<string name="confirm">Подтвердить</string> <string name="confirm">Подтвердить</string>
<string name="cancel">Отмена</string> <string name="cancel">Отмена</string>
@@ -281,6 +282,7 @@
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string> <string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
<string name="more">Ещё</string> <string name="more">Ещё</string>
<string name="unread_count">+%1$d</string> <string name="unread_count">+%1$d</string>
<string name="unknown">Неизвестно</string>
<!-- Удаления доступа --> <!-- Удаления доступа -->
<string name="suspend_chat_banner_message">Ваш аккаунт был заблокирован</string> <string name="suspend_chat_banner_message">Ваш аккаунт был заблокирован</string>
@@ -181,9 +181,7 @@
<string name="server_ip_label">Server IP or hostname</string> <string name="server_ip_label">Server IP or hostname</string>
<string name="server_ip_hint">192.168.1.10</string> <string name="server_ip_hint">192.168.1.10</string>
<string name="api_port_label">Port</string> <string name="api_port_label">Port</string>
<string name="api_port_hint"></string>
<string name="calls_port_label">Calls port</string> <string name="calls_port_label">Calls port</string>
<string name="calls_port_hint"></string>
<string name="https_enabled">Secure connection</string> <string name="https_enabled">Secure connection</string>
<string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string> <string name="server_config_https_hint">Uses HTTPS. Turn off only for plain HTTP.</string>
<string name="server_config_https_headline">HTTPS</string> <string name="server_config_https_headline">HTTPS</string>
@@ -243,13 +241,11 @@
<string name="settings_category_account_d">Log out or delete account</string> <string name="settings_category_account_d">Log out or delete account</string>
<string name="settings_notifications_title">Notifications</string> <string name="settings_notifications_title">Notifications</string>
<string name="settings_notifications_body">To allow or block alerts from FromChat, use your phones notification settings.</string> <string name="settings_notification_settings">Notification settings</string>
<string name="settings_open_notification_settings">Open notification settings</string> <string name="settings_notification_settings_d">Here you can enable or disable certain types of notifications, change the sound and more.</string>
<string name="settings_notifications_enable">Enable push notifications</string>
<string name="settings_notifications_disable">Disable push notifications</string>
<string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string> <string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string>
<string name="settings_push_notifications_enabled">Push notifications enabled</string> <string name="settings_push_notifications">Push notifications</string>
<string name="settings_push_notifications_disabled">Push notifications disabled</string> <string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string>
<string name="settings_devices_title">Devices</string> <string name="settings_devices_title">Devices</string>
<string name="settings_devices_empty">No active sessions</string> <string name="settings_devices_empty">No active sessions</string>
@@ -276,6 +272,7 @@
<string name="settings_devices_logout_all">Sign out all other devices</string> <string name="settings_devices_logout_all">Sign out all other devices</string>
<string name="settings_devices_logout_all_confirm_title">Sign out everywhere else?</string> <string name="settings_devices_logout_all_confirm_title">Sign out everywhere else?</string>
<string name="settings_devices_logout_all_confirm_body">You stay signed in on this device only.</string> <string name="settings_devices_logout_all_confirm_body">You stay signed in on this device only.</string>
<string name="settings_devices_active_sessions">Active sessions</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
@@ -308,6 +305,7 @@
<string name="error_invalid_credentials">Wrong username or password</string> <string name="error_invalid_credentials">Wrong username or password</string>
<string name="error_connection">Couldnt connect. Check your internet.</string> <string name="error_connection">Couldnt connect. Check your internet.</string>
<string name="error_unknown">Something went wrong. Please try again.</string> <string name="error_unknown">Something went wrong. Please try again.</string>
<string name="unknown">Unknown</string>
<!-- Typing Indicators --> <!-- Typing Indicators -->
<string name="typing_single">%1$s is typing…</string> <string name="typing_single">%1$s is typing…</string>
@@ -4,10 +4,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
/**
* Mirrors process visibility: [setForeground] from [androidx.lifecycle.Lifecycle.Event.ON_START] /
* [androidx.lifecycle.Lifecycle.Event.ON_STOP] (or initial [syncFromLifecycle]).
*/
object AppForeground { object AppForeground {
private val _isInForeground = MutableStateFlow(true) private val _isInForeground = MutableStateFlow(true)
val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow() val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow()
@@ -1,4 +1,4 @@
package ru.fromchat.core package ru.fromchat
expect object Logger { expect object Logger {
fun d(tag: String, message: String, throwable: Throwable? = null) fun d(tag: String, message: String, throwable: Throwable? = null)
@@ -6,4 +6,3 @@ expect object Logger {
fun w(tag: String, message: String, throwable: Throwable? = null) fun w(tag: String, message: String, throwable: Throwable? = null)
fun e(tag: String, message: String, throwable: Throwable? = null) fun e(tag: String, message: String, throwable: Throwable? = null)
} }
@@ -1,4 +0,0 @@
package ru.fromchat
expect fun showProfileLoadErrorMessage(message: String)
@@ -1,44 +0,0 @@
package ru.fromchat
import kotlin.collections.HashMap
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.InternalResourceApi
@OptIn(InternalResourceApi::class)
private fun collectCommonMainStringResources(): HashMap<String, StringResource> {
val map = HashMap<String, StringResource>()
_collectCommonMainString0Resources(map)
_collectCommonMainString1Resources(map)
return map
}
internal fun resolveSearchHintResource(): StringResource {
val map = collectCommonMainStringResources()
return map["search_hint"]
?: map["app_name"]
?: error("Search hint resource is missing")
}
internal fun resolveSearchNotFoundTitleResource(): StringResource {
val map = collectCommonMainStringResources()
return map["search_not_found"]
?: map["search_not_found_message"]
?: map["app_name"]
?: error("Search not-found title resource is missing")
}
internal fun resolveSearchNotFoundMessageResource(): StringResource {
val map = collectCommonMainStringResources()
return map["search_not_found_message"]
?: map["search_not_found"]
?: map["app_name"]
?: error("Search not-found message resource is missing")
}
internal fun resolveSearchTitleResource(): StringResource {
val map = collectCommonMainStringResources()
return map["search_title"]
?: map["search_hint"]
?: map["app_name"]
?: error("Search title resource is missing")
}
@@ -1,5 +1,8 @@
package ru.fromchat.api package ru.fromchat.api
import com.pr0gramm3r101.utils.crypto.Base64
import com.pr0gramm3r101.utils.currentDeviceInfo
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import com.pr0gramm3r101.utils.settings.secureSettings import com.pr0gramm3r101.utils.settings.secureSettings
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -12,46 +15,90 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.request.header
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.pingInterval import io.ktor.client.plugins.websocket.pingInterval
import io.ktor.client.request.bearerAuth import io.ktor.client.request.bearerAuth
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.http.HttpHeaders import io.ktor.client.request.header
import io.ktor.http.HttpStatusCode
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import io.ktor.client.request.patch
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.Settings import ru.fromchat.api.ApiClient.logout
import ru.fromchat.core.config.Config import ru.fromchat.api.ApiClient.persistSessionToStorage
import ru.fromchat.core.instance.InstanceIdGuard import ru.fromchat.api.crypto.IdentityKeyManager
import ru.fromchat.api.db.InstanceRegistryStore import ru.fromchat.api.crypto.transport.TransportCiphertext
import ru.fromchat.ui.chat.PublicChatPanelCache import ru.fromchat.api.crypto.transport.TransportCrypto
import ru.fromchat.ui.dm.DmPanelCache import ru.fromchat.api.instance.InstanceIdGuard
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.readOutboundFileBytes
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.download.streamEncryptedFileToDisk
import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl
import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload
import ru.fromchat.api.schema.calls.LiveKitTokenRequest
import ru.fromchat.api.schema.calls.LiveKitTokenResponse
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
import ru.fromchat.api.schema.messages.dm.EditDmRequest
import ru.fromchat.api.schema.messages.dm.SendDmFile
import ru.fromchat.api.schema.messages.dm.SendDmRequest
import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkRequest
import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadCompleteResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitRequest
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse
import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest
import ru.fromchat.api.schema.server.RegisteredUserCountResponse
import ru.fromchat.api.schema.server.ServerInstanceIdResponse
import ru.fromchat.api.schema.server.TransportKeyResponse
import ru.fromchat.api.schema.user.ChangePasswordApiRequest
import ru.fromchat.api.schema.user.FcmTokenRequest
import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterRequest
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
import ru.fromchat.api.schema.user.devices.DevicesListResponse
import ru.fromchat.api.schema.user.keys.BackupBlobRequest
import ru.fromchat.api.schema.user.keys.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
import ru.fromchat.api.schema.user.profile.SimilarityResult
import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerifyResponse
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.requests.WebSocketDeleteDmRequest
import ru.fromchat.api.schema.websocket.requests.WebSocketDeleteMessageRequest
import ru.fromchat.api.schema.websocket.requests.WebSocketEditMessageRequest
import ru.fromchat.api.schema.websocket.requests.WebSocketSendMessageRequest
import ru.fromchat.api.schema.websocket.types.DmTypingData
import ru.fromchat.api.schema.websocket.types.SubscribeStatusData
import ru.fromchat.config.ServerConfig
import ru.fromchat.config.Settings
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import com.pr0gramm3r101.utils.crypto.Base64
import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.transport.TransportCiphertext
import ru.fromchat.crypto.transport.TransportCrypto
import ru.fromchat.platform.currentDeviceInfo
import ru.fromchat.fcm.unregisterFcmTokenFromServer
/** /**
* Creates a platform-specific HTTP client that supports WebSockets * Creates a platform-specific HTTP client that supports WebSockets
@@ -157,7 +204,7 @@ object ApiClient {
validateResponse { response -> validateResponse { response ->
val instanceHeader = response.headers[InstanceIdGuard.INSTANCE_ID_HEADER] val instanceHeader = response.headers[InstanceIdGuard.INSTANCE_ID_HEADER]
runCatching { runCatching {
Config.serverConfig.value?.let { cfg -> ServerConfig.serverConfig.value?.let { cfg ->
InstanceIdGuard.onResponseHeader(instanceHeader, cfg) InstanceIdGuard.onResponseHeader(instanceHeader, cfg)
} }
} }
@@ -244,7 +291,7 @@ object ApiClient {
suspend fun refreshServerInstanceFingerprint() { suspend fun refreshServerInstanceFingerprint() {
runCatching { runCatching {
val id = fetchServerInstanceId(Config.apiBaseUrl) val id = fetchServerInstanceId(ServerConfig.apiBaseUrl)
if (id.isNotEmpty()) { if (id.isNotEmpty()) {
Settings.lastKnownServerInstanceId = id Settings.lastKnownServerInstanceId = id
InstanceRegistryStore.registerInstanceEncountered(id) InstanceRegistryStore.registerInstanceEncountered(id)
@@ -315,14 +362,14 @@ object ApiClient {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
ru.fromchat.core.Logger.e("ApiClient", "Error loading persisted data", e) ru.fromchat.Logger.e("ApiClient", "Error loading persisted data", e)
} }
} }
suspend fun loginRequest(request: LoginRequest): LoginResponse = suspend fun loginRequest(request: LoginRequest): LoginResponse =
http http
.post("${Config.apiBaseUrl}/login") { .post("${ServerConfig.apiBaseUrl}/login") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -330,7 +377,7 @@ object ApiClient {
suspend fun registerRequest(request: RegisterRequest): LoginResponse = suspend fun registerRequest(request: RegisterRequest): LoginResponse =
http http
.post("${Config.apiBaseUrl}/register") { .post("${ServerConfig.apiBaseUrl}/register") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -365,7 +412,7 @@ object ApiClient {
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http http
.get("${Config.apiBaseUrl}/get_messages") { .get("${ServerConfig.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
parameter("limit", limit) parameter("limit", limit)
beforeId?.let { parameter("before_id", it) } beforeId?.let { parameter("before_id", it) }
@@ -374,28 +421,28 @@ object ApiClient {
suspend fun getOwnProfile(): UserProfile = suspend fun getOwnProfile(): UserProfile =
http http
.get("${Config.apiBaseUrl}/user/profile") { .get("${ServerConfig.apiBaseUrl}/user/profile") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
suspend fun getProfileById(userId: Int): UserProfile = suspend fun getProfileById(userId: Int): UserProfile =
http http
.get("${Config.apiBaseUrl}/user/id/$userId") { .get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
suspend fun getProfileByUsername(username: String): UserProfile = suspend fun getProfileByUsername(username: String): UserProfile =
http http
.get("${Config.apiBaseUrl}/user/$username") { .get("${ServerConfig.apiBaseUrl}/user/$username") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
suspend fun getRegisteredUserCount(): Int = suspend fun getRegisteredUserCount(): Int =
http http
.get("${Config.apiBaseUrl}/user/stats/registered-count") { .get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body<RegisteredUserCountResponse>() .body<RegisteredUserCountResponse>()
@@ -404,7 +451,7 @@ object ApiClient {
suspend fun checkSimilarity(userId: Int): SimilarityResult? = suspend fun checkSimilarity(userId: Int): SimilarityResult? =
runCatching { runCatching {
http http
.get("${Config.apiBaseUrl}/user/check-similarity/$userId") { .get("${ServerConfig.apiBaseUrl}/user/check-similarity/$userId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body<SimilarityResult>() .body<SimilarityResult>()
@@ -413,7 +460,7 @@ object ApiClient {
suspend fun verifyUser(userId: Int): VerifyResponse? = suspend fun verifyUser(userId: Int): VerifyResponse? =
runCatching { runCatching {
http http
.post("${Config.apiBaseUrl}/user/$userId/verify") { .post("${ServerConfig.apiBaseUrl}/user/$userId/verify") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body<VerifyResponse>() .body<VerifyResponse>()
@@ -421,7 +468,7 @@ object ApiClient {
suspend fun getDmConversations(): List<DmConversation> = suspend fun getDmConversations(): List<DmConversation> =
http http
.get("${Config.apiBaseUrl}/dm/conversations") { .get("${ServerConfig.apiBaseUrl}/dm/conversations") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body<DmConversationsResponse>() .body<DmConversationsResponse>()
@@ -429,7 +476,7 @@ object ApiClient {
suspend fun getDmFetch(since: Int? = null): DmHistoryResponse { suspend fun getDmFetch(since: Int? = null): DmHistoryResponse {
return http return http
.get("${Config.apiBaseUrl}/dm/fetch") { .get("${ServerConfig.apiBaseUrl}/dm/fetch") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
since?.let { parameter("since", it) } since?.let { parameter("since", it) }
} }
@@ -442,7 +489,7 @@ object ApiClient {
beforeId: Int? = null beforeId: Int? = null
): DmHistoryResponse = ): DmHistoryResponse =
http http
.get("${Config.apiBaseUrl}/dm/history/$otherUserId") { .get("${ServerConfig.apiBaseUrl}/dm/history/$otherUserId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
parameter("limit", limit) parameter("limit", limit)
beforeId?.let { parameter("before_id", it) } beforeId?.let { parameter("before_id", it) }
@@ -451,21 +498,21 @@ object ApiClient {
suspend fun getOwnPublicKey(): PublicKeyResponse = suspend fun getOwnPublicKey(): PublicKeyResponse =
http http
.get("${Config.apiBaseUrl}/crypto/public-key") { .get("${ServerConfig.apiBaseUrl}/crypto/public-key") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
suspend fun getUserPublicKey(userId: Int): PublicKeyResponse = suspend fun getUserPublicKey(userId: Int): PublicKeyResponse =
http http
.get("${Config.apiBaseUrl}/crypto/public-key/of/$userId") { .get("${ServerConfig.apiBaseUrl}/crypto/public-key/of/$userId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
suspend fun getTransportPublicKey(): TransportKeyResponse = suspend fun getTransportPublicKey(): TransportKeyResponse =
http http
.get("${Config.apiBaseUrl}/dm/key/transport/public") { .get("${ServerConfig.apiBaseUrl}/dm/key/transport/public") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
@@ -512,7 +559,7 @@ object ApiClient {
uploadedFileIds = uploadedFileIds uploadedFileIds = uploadedFileIds
) )
http.post("${Config.apiBaseUrl}/dm/send") { http.post("${ServerConfig.apiBaseUrl}/dm/send") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(body) setBody(body)
} }
@@ -524,7 +571,7 @@ object ApiClient {
recipientId: Int, recipientId: Int,
chunkSize: Int? = null chunkSize: Int? = null
): DmUploadInitResponse = ): DmUploadInitResponse =
http.post("${Config.apiBaseUrl}/dm/upload/init") { http.post("${ServerConfig.apiBaseUrl}/dm/upload/init") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(
DmUploadInitRequest( DmUploadInitRequest(
@@ -537,7 +584,7 @@ object ApiClient {
}.body() }.body()
suspend fun getDmUploadStatus(uploadId: String): DmUploadStatusResponse = suspend fun getDmUploadStatus(uploadId: String): DmUploadStatusResponse =
http.get("${Config.apiBaseUrl}/dm/upload/$uploadId") { http.get("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
}.body() }.body()
@@ -546,7 +593,7 @@ object ApiClient {
offset: Long, offset: Long,
dataB64: String dataB64: String
): DmUploadChunkResponse = ): DmUploadChunkResponse =
http.patch("${Config.apiBaseUrl}/dm/upload/$uploadId") { http.patch("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(
DmUploadChunkRequest( DmUploadChunkRequest(
@@ -557,13 +604,13 @@ object ApiClient {
}.body() }.body()
suspend fun completeDmUpload(uploadId: String): DmUploadCompleteResponse = suspend fun completeDmUpload(uploadId: String): DmUploadCompleteResponse =
http.post("${Config.apiBaseUrl}/dm/upload/$uploadId/complete") { http.post("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId/complete") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf("upload_id" to uploadId)) setBody(mapOf("upload_id" to uploadId))
}.body() }.body()
suspend fun abortDmUpload(uploadId: String) { suspend fun abortDmUpload(uploadId: String) {
http.delete("${Config.apiBaseUrl}/dm/upload/$uploadId") { http.delete("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
} }
@@ -575,10 +622,10 @@ object ApiClient {
fun encryptedFileUrl(path: String): String = when { fun encryptedFileUrl(path: String): String = when {
path.startsWith("http") -> path path.startsWith("http") -> path
path.startsWith("/api") -> { path.startsWith("/api") -> {
val serverBase = Config.apiBaseUrl.removeSuffix("/api") val serverBase = ServerConfig.apiBaseUrl.removeSuffix("/api")
"$serverBase$path" "$serverBase$path"
} }
else -> "${Config.apiBaseUrl}$path" else -> "${ServerConfig.apiBaseUrl}$path"
} }
/** Encrypted ciphertext stored on disk after a resumable download. */ /** Encrypted ciphertext stored on disk after a resumable download. */
@@ -659,7 +706,7 @@ object ApiClient {
return EncryptedFileOnDisk(outputPath, received) return EncryptedFileOnDisk(outputPath, received)
} }
private fun currentDownloadUserAgent(): String? { private fun currentDownloadUserAgent(): String {
val currentDevice = currentDeviceInfo() val currentDevice = currentDeviceInfo()
return buildLoginUserAgent( return buildLoginUserAgent(
osName = currentDevice.osName?.takeIf { it.isNotBlank() }, osName = currentDevice.osName?.takeIf { it.isNotBlank() },
@@ -886,7 +933,7 @@ object ApiClient {
private suspend fun readPartialDownloadMetaFileFromDisk(path: String): PartialDownloadMeta? { private suspend fun readPartialDownloadMetaFileFromDisk(path: String): PartialDownloadMeta? {
if (!PlatformFileSystem.exists(path)) return null if (!PlatformFileSystem.exists(path)) return null
val text = runCatching { val text = runCatching {
ru.fromchat.core.cache.readOutboundFileBytes("file://$path").decodeToString() readOutboundFileBytes("file://$path").decodeToString()
}.getOrNull() ?: return null }.getOrNull() ?: return null
return parsePartialDownloadMeta(text) return parsePartialDownloadMeta(text)
} }
@@ -946,7 +993,7 @@ object ApiClient {
val path = pausedDownloadIndexPath() ?: return emptySet() val path = pausedDownloadIndexPath() ?: return emptySet()
if (!PlatformFileSystem.exists(path)) return emptySet() if (!PlatformFileSystem.exists(path)) return emptySet()
return runCatching { return runCatching {
ru.fromchat.core.cache.readOutboundFileBytes("file://$path") readOutboundFileBytes("file://$path")
.decodeToString() .decodeToString()
.lineSequence() .lineSequence()
.map { it.trim() } .map { it.trim() }
@@ -1027,7 +1074,7 @@ object ApiClient {
recipientPublicKeyB64 = recipientPublicKey recipientPublicKeyB64 = recipientPublicKey
) )
http.put("${Config.apiBaseUrl}/dm/edit/$messageId") { http.put("${ServerConfig.apiBaseUrl}/dm/edit/$messageId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(body) setBody(body)
} }
@@ -1035,20 +1082,20 @@ object ApiClient {
suspend fun fetchBackupBlob(): String? { suspend fun fetchBackupBlob(): String? {
return try { return try {
val response = http.get("${Config.apiBaseUrl}/crypto/backup") { val response = http.get("${ServerConfig.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
val backupResponse = response.body<BackupBlobResponse>() val backupResponse = response.body<BackupBlobResponse>()
backupResponse.blob backupResponse.blob
} catch (e: Exception) { } catch (e: Exception) {
ru.fromchat.core.Logger.d("ApiClient", "No backup found or error fetching: ${e.message}") ru.fromchat.Logger.d("ApiClient", "No backup found or error fetching: ${e.message}")
null null
} }
} }
suspend fun uploadBackupBlob(blobJson: String) { suspend fun uploadBackupBlob(blobJson: String) {
val payload = BackupBlobRequest(blob = blobJson) val payload = BackupBlobRequest(blob = blobJson)
http.post("${Config.apiBaseUrl}/crypto/backup") { http.post("${ServerConfig.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(payload) setBody(payload)
} }
@@ -1058,7 +1105,7 @@ object ApiClient {
suspend fun validateToken(): Boolean { suspend fun validateToken(): Boolean {
try { try {
http http
.get("${Config.apiBaseUrl}/api/user/profile") .get("${ServerConfig.apiBaseUrl}/api/user/profile")
return true // Token is valid if no exception thrown return true // Token is valid if no exception thrown
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {
if (e.response.status.value == 401 || e.response.status.value == 403) { if (e.response.status.value == 401 || e.response.status.value == 403) {
@@ -1073,27 +1120,27 @@ object ApiClient {
suspend fun listDevices(): List<DeviceSessionInfo> = suspend fun listDevices(): List<DeviceSessionInfo> =
http http
.get("${Config.apiBaseUrl}/devices") { .get("${ServerConfig.apiBaseUrl}/devices") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body<DevicesListResponse>() .body<DevicesListResponse>()
.devices .devices
suspend fun revokeDeviceSession(sessionId: String) { suspend fun revokeDeviceSession(sessionId: String) {
http.delete("${Config.apiBaseUrl}/devices/$sessionId") { http.delete("${ServerConfig.apiBaseUrl}/devices/$sessionId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
} }
suspend fun revokeAllOtherDeviceSessions() { suspend fun revokeAllOtherDeviceSessions() {
http.post("${Config.apiBaseUrl}/devices/logout-all") { http.post("${ServerConfig.apiBaseUrl}/devices/logout-all") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
} }
suspend fun registerFcmToken(token: String): SimpleStatusResponse { suspend fun registerFcmToken(token: String): SimpleStatusResponse {
return http return http
.post("${Config.apiBaseUrl}/push/register") { .post("${ServerConfig.apiBaseUrl}/push/register") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(FcmTokenRequest(token = token)) setBody(FcmTokenRequest(token = token))
} }
@@ -1103,13 +1150,13 @@ object ApiClient {
suspend fun unregisterFcmToken(token: String? = null): SimpleStatusResponse { suspend fun unregisterFcmToken(token: String? = null): SimpleStatusResponse {
return if (token.isNullOrBlank()) { return if (token.isNullOrBlank()) {
http http
.post("${Config.apiBaseUrl}/push/unregister") { .post("${ServerConfig.apiBaseUrl}/push/unregister") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
} else { } else {
http http
.post("${Config.apiBaseUrl}/push/unregister") { .post("${ServerConfig.apiBaseUrl}/push/unregister") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(FcmTokenRequest(token = token)) setBody(FcmTokenRequest(token = token))
} }
@@ -1122,7 +1169,7 @@ object ApiClient {
newPasswordDerived: String, newPasswordDerived: String,
logoutAllExceptCurrent: Boolean logoutAllExceptCurrent: Boolean
) { ) {
http.post("${Config.apiBaseUrl}/change-password") { http.post("${ServerConfig.apiBaseUrl}/change-password") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(
ChangePasswordApiRequest( ChangePasswordApiRequest(
@@ -1140,14 +1187,14 @@ object ApiClient {
suspend fun deleteAccount(): SimpleStatusResponse { suspend fun deleteAccount(): SimpleStatusResponse {
try { try {
return http return http
.post("${Config.apiBaseUrl}/account/delete") { .post("${ServerConfig.apiBaseUrl}/account/delete") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {
if (e.response.status.value != 404) throw e if (e.response.status.value != 404) throw e
return http return http
.post("${Config.apiBaseUrl}/delete") { .post("${ServerConfig.apiBaseUrl}/delete") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
@@ -1177,7 +1224,7 @@ object ApiClient {
} }
suspend fun logout() { suspend fun logout() {
runCatching { http.get("${Config.apiBaseUrl}/logout") } runCatching { http.get("${ServerConfig.apiBaseUrl}/logout") }
runCatching { unregisterFcmTokenFromServer() } runCatching { unregisterFcmTokenFromServer() }
clearLocalSession() clearLocalSession()
} }
@@ -1186,7 +1233,7 @@ object ApiClient {
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
if (_suspensionState.value.isSuspended) return if (_suspensionState.value.isSuspended) return
http.post("${Config.apiBaseUrl}/send_message") { http.post("${ServerConfig.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) setBody(SendMessageRequest(content = content, reply_to_id = replyToId))
} }
@@ -1366,7 +1413,7 @@ object ApiClient {
throw IllegalStateException("Suspended") throw IllegalStateException("Suspended")
} }
return http return http
.post("${Config.apiBaseUrl}/livekit/token") { .post("${ServerConfig.apiBaseUrl}/livekit/token") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(LiveKitTokenRequest(peerUserId = peerUserId, roomName = roomName)) setBody(LiveKitTokenRequest(peerUserId = peerUserId, roomName = roomName))
} }
@@ -1,10 +1,9 @@
package ru.fromchat.core package ru.fromchat.api
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
/** /**
* Network work that must not block cold start or the first frame. * Network work that must not block cold start or the first frame.
@@ -1,4 +1,4 @@
package ru.fromchat.fcm package ru.fromchat.api
expect suspend fun uploadPendingFcmTokenIfAvailable() expect suspend fun uploadPendingFcmTokenIfAvailable()
@@ -1,476 +0,0 @@
package ru.fromchat.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class RegisterRequest(
val username: String,
val display_name: String,
val password: String,
val confirm_password: String
)
@Serializable
data class ErrorResponse(
val detail: String
)
@Serializable
data class User(
val id: Int,
val created_at: String,
val last_seen: String,
val online: Boolean,
val username: String,
@SerialName("display_name") val displayName: String? = null,
val admin: Boolean? = null,
val bio: String? = null,
val profile_picture: String? = null,
val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null
)
@Serializable
data class UserProfile(
val id: Int,
val username: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("profile_picture") val profilePicture: String? = null,
val bio: String? = null,
val online: Boolean = false,
@SerialName("last_seen") val lastSeen: String? = null,
@SerialName("created_at") val createdAt: String? = null,
val verified: Boolean? = null,
val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null,
/**
* Client-only: true when this row was built from public-chat message metadata, not a full
* `/user/...` response. The backend does not send this key.
*/
@SerialName("client_preview_only") val isClientPreviewOnly: Boolean = false
)
@Serializable
data class ProfileDialogData(
@SerialName("user_id") val userId: Int? = null,
val username: String? = null,
@SerialName("display_name") val displayName: String? = null,
@SerialName("profile_picture") val profilePicture: String? = null,
val bio: String? = null,
@SerialName("member_since") val memberSince: String? = null,
val online: Boolean? = null,
@SerialName("is_own_profile") val isOwnProfile: Boolean = false,
val verified: Boolean? = null,
val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null
)
@Serializable
data class LoginResponse(
val user: User,
val token: String
)
@Serializable
data class DevicesListResponse(
val devices: List<DeviceSessionInfo> = emptyList()
)
@Serializable
data class DeviceSessionInfo(
@SerialName("session_id") val sessionId: String,
@SerialName("device_name") val deviceName: String? = null,
@SerialName("device_type") val deviceType: String? = null,
@SerialName("os_name") val osName: String? = null,
@SerialName("os_version") val osVersion: String? = null,
@SerialName("browser_name") val browserName: String? = null,
@SerialName("browser_version") val browserVersion: String? = null,
val brand: String? = null,
val model: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("last_seen") val lastSeen: String? = null,
val revoked: Boolean? = null,
val current: Boolean = false
)
@Serializable
data class ChangePasswordApiRequest(
val currentPasswordDerived: String,
val newPasswordDerived: String,
val logoutAllExceptCurrent: Boolean = false
)
@Serializable
data class SimpleStatusResponse(
val status: String? = null,
val message: String? = null
)
@Serializable
data class ServerInstanceIdResponse(
@SerialName("instance_id") val instanceId: String,
)
@Serializable
data class CheckAuthResponse(
val authenticated: Boolean = false,
val username: String? = null,
val admin: Boolean? = null,
)
@Serializable
data class MessagesResponse(
val status: String,
val messages: List<Message>
)
@Serializable
data class Message(
val id: Int,
val user_id: Int,
val content: String,
val timestamp: String,
val is_read: Boolean,
val is_edited: Boolean,
val username: String,
val profile_picture: String? = null,
val verified: Boolean? = null,
val reply_to: Message? = null,
val client_message_id: String? = null,
val reactions: List<ReactionData>? = null,
val files: List<DmFile>? = null,
/** For optimistic UI: local URI when sending, null when confirmed. */
val pendingFileUri: String? = null,
/** For optimistic UI: filename when sending file (non-image), null when confirmed. */
val pendingFilename: String? = null,
/** For optimistic UI: aspect ratio (width/height) when sending image, null when confirmed. */
val pendingFileAspectRatio: Float? = null,
/** For optimistic UI: jobId to track upload progress. */
val uploadJobId: String? = null,
/** For optimistic UI: 0-100 upload progress, null when complete. */
val uploadProgress: Int? = null,
/** Set when outbound upload failed; use [UPLOAD_ERROR_FILE_TOO_LARGE] for localized copy. */
@kotlinx.serialization.Transient val uploadError: String? = null,
/** For DM file decryption; not serialized over network. */
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null,
/** Blurhashes for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileThumbnails: List<String>? = null,
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null,
/** File sizes in bytes (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileSizes: List<Long>? = null,
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileDimensions: List<Pair<Int, Int>>? = null,
/** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */
@kotlinx.serialization.Transient val isContentCorrupted: Boolean = false
)
@Serializable
data class SendMessageRequest(
val content: String,
val reply_to_id: Int? = null
)
@Serializable
data class EditMessageRequest(
val content: String
)
@Serializable
data class SendMessageResponse(
val status: String,
val message: Message
)
// WebSocket types mirror frontend src/core/types.d.ts
@Serializable
data class WebSocketCredentials(
val scheme: String,
val credentials: String
)
@Serializable
data class WebSocketError(
val code: Int,
val detail: String
)
@Serializable
data class WebSocketMessage(
val type: String,
val credentials: WebSocketCredentials? = null,
val data: JsonElement? = null,
val error: WebSocketError? = null
)
// WebSocket message data types
@Serializable
data class NewMessageData(
val message: Message
)
@Serializable
data class MessageEditedData(
val message: Message
)
@Serializable
data class MessageDeletedData(
val message_id: Int
)
@Serializable
data class TypingData(
val userId: Int,
val username: String
)
@Serializable
data class DmFile(
val id: Int,
val name: String,
val path: String,
@SerialName("dm_envelope_id") val dmEnvelopeId: Int? = null,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@SerialName("nonce_b64") val nonceB64: String? = null
)
@Serializable
data class DmEnvelope(
val id: Int,
val senderId: Int,
val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null,
@SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
val timestamp: String,
@SerialName("client_message_id") val clientMessageId: String? = null,
@SerialName("reply_to_id") val replyToId: Int? = null,
val files: List<DmFile>? = null
)
@Serializable
data class DmConversation(
val user: User,
val lastMessage: DmEnvelope,
val unreadCount: Int
)
@Serializable
data class RegisteredUserCountResponse(
val count: Int
)
@Serializable
data class DmConversationsResponse(
val conversations: List<DmConversation> = emptyList()
)
@Serializable
data class DmHistoryResponse(
val messages: List<DmEnvelope> = emptyList(),
@SerialName("has_more") val hasMore: Boolean? = null
)
@Serializable
data class PublicKeyResponse(
val publicKey: String? = null
)
@Serializable
data class SendDmFile(
@SerialName("encrypted_file_data_b64") val encryptedFileDataB64: String,
val filename: String,
@SerialName("file_size") val fileSize: Long
)
@Serializable
data class SendDmRequest(
@SerialName("recipient_id") val recipientId: Int,
@SerialName("client_public_key_b64") val clientPublicKeyB64: String,
@SerialName("transport_nonce_b64") val transportNonceB64: String,
@SerialName("transport_ciphertext_b64") val transportCiphertextB64: String,
@SerialName("sender_public_key_b64") val senderPublicKeyB64: String,
@SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String,
@SerialName("client_message_id") val clientMessageId: String? = null,
@SerialName("reply_to_id") val replyToId: Int? = null,
@SerialName("transport_files") val transportFiles: List<SendDmFile> = emptyList(),
@SerialName("uploaded_file_ids") val uploadedFileIds: List<String> = emptyList()
)
@Serializable
data class EditDmRequest(
@SerialName("client_public_key_b64") val clientPublicKeyB64: String,
@SerialName("transport_nonce_b64") val transportNonceB64: String,
@SerialName("transport_ciphertext_b64") val transportCiphertextB64: String,
@SerialName("sender_public_key_b64") val senderPublicKeyB64: String,
@SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String
)
@Serializable
data class TransportKeyResponse(
@SerialName("key_id") val keyId: String,
@SerialName("public_key_b64") val publicKeyB64: String,
@SerialName("created_at") val createdAt: Double? = null
)
@Serializable
data class DmUploadInitRequest(
val filename: String,
@SerialName("total_size") val totalSize: Long,
@SerialName("recipient_id") val recipientId: Int,
@SerialName("chunk_size") val chunkSize: Int? = null
)
@Serializable
data class DmUploadInitResponse(
@SerialName("upload_id") val uploadId: String,
@SerialName("chunk_size") val chunkSize: Int,
val offset: Long = 0L
)
@Serializable
data class DmUploadStatusResponse(
@SerialName("upload_id") val uploadId: String,
val filename: String,
@SerialName("total_size") val totalSize: Long,
val offset: Long,
val complete: Boolean
)
@Serializable
data class DmUploadChunkRequest(
val offset: Long,
@SerialName("data_b64") val dataB64: String
)
@Serializable
data class DmUploadChunkResponse(
@SerialName("offset_received") val offsetReceived: Long
)
@Serializable
data class DmUploadCompleteResponse(
@SerialName("file_id") val fileId: String,
@SerialName("upload_id") val uploadId: String
)
// Batched updates message
@Serializable
data class UpdateItem(
val type: String,
val data: JsonElement? = null
)
@Serializable
data class UpdatesMessage(
val type: String,
val seq: Int,
val updates: List<UpdateItem>
)
// WebSocket request types
@Serializable
data class WebSocketSendMessageRequest(
val content: String,
val reply_to_id: Int? = null,
val client_message_id: String? = null
)
@Serializable
data class WebSocketEditMessageRequest(
val message_id: Int,
val content: String
)
@Serializable
data class WebSocketDeleteMessageRequest(
val message_id: Int
)
@Serializable
data class WebSocketDeleteDmRequest(
val id: Int,
@SerialName("recipientId") val recipientId: Int,
)
@Serializable
data class DmDeletedData(
val id: Int,
@SerialName("senderId") val senderId: Int,
@SerialName("recipientId") val recipientId: Int? = null,
)
@Serializable
data class DmTypingData(
@SerialName("recipientId") val recipientId: Int
)
@Serializable
data class SubscribeStatusData(
@SerialName("userId") val userId: Int
)
@Serializable
data class BackupBlobResponse(
val blob: String?
)
@Serializable
data class BackupBlobRequest(
val blob: String
)
@Serializable
data class SimilarityResult(
@SerialName("isSimilar") val isSimilar: Boolean,
@SerialName("similarTo") val similarTo: String? = null
)
@Serializable
data class VerifyResponse(
val verified: Boolean
)
@Serializable
data class FcmTokenRequest(
val token: String
)
@Serializable
data class LiveKitTokenRequest(
@SerialName("peer_user_id") val peerUserId: Int,
@SerialName("room_name") val roomName: String? = null,
)
@Serializable
data class LiveKitTokenResponse(
@SerialName("server_url") val serverUrl: String,
val token: String,
@SerialName("room_name") val roomName: String,
)
@Serializable
data class CallSignalingLiveKitPayload(
val toUserId: Int,
val roomName: String,
val serverUrl: String,
)
@Serializable
data class CallSignalingLiveKitControl(
val toUserId: Int,
val kind: String,
val roomName: String? = null,
)
@@ -1,13 +1,20 @@
package ru.fromchat.api package ru.fromchat.api
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.core.Logger import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
/** /**
@@ -35,6 +42,7 @@ object UpdateSyncManager {
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null) ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
} }
@OptIn(DelicateCoroutinesApi::class)
fun onUpdatesBatch(seq: Int) { fun onUpdatesBatch(seq: Int) {
if (seq <= 0) return if (seq <= 0) return
val currentUserId = ApiClient.user?.id ?: return val currentUserId = ApiClient.user?.id ?: return
@@ -1,8 +1,9 @@
package ru.fromchat.api package ru.fromchat.api
import ru.fromchat.core.Logger
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.Logger
suspend inline fun <Response> apiRequest( suspend inline fun <Response> apiRequest(
unexpectedError: String, unexpectedError: String,
@@ -1,51 +0,0 @@
package ru.fromchat.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WebSocketUpdatesData(
val seq: Int,
val updates: List<WebSocketMessage>
)
@Serializable
data class GetUpdatesRequest(
@SerialName("lastSeq") val lastSeq: Int
)
@Serializable
data class GetUpdatesResponse(
val status: String,
@SerialName("lastSeq") val lastSeq: Int,
@SerialName("missedCount") val missedCount: Int
)
@Serializable
data class ReactionUpdateData(
val message_id: Int,
val emoji: String,
val action: String,
val user_id: Int,
val username: String,
val reactions: List<ReactionData>
)
@Serializable
data class ReactionData(
val emoji: String,
val count: Int,
val users: List<ReactionUser>
)
@Serializable
data class ReactionUser(
val id: Int,
val username: String
)
@Serializable
data class TypingUpdateData(
val userId: Int,
val username: String
)
@@ -1,4 +1,4 @@
package ru.fromchat.calls package ru.fromchat.api.calls
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -14,11 +14,11 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.visibleDisplayName import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.core.Logger import ru.fromchat.Logger
import ru.fromchat.core.config.Config import ru.fromchat.config.ServerConfig
private const val TAG = "CallStore" private const val TAG = "CallStore"
@@ -105,7 +105,7 @@ object CallStore {
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
if (serverUrl == null || roomName == null || fromUserId == null) return if (serverUrl == null || roomName == null || fromUserId == null) return
if (fromUserId == currentId) return if (fromUserId == currentId) return
if (!Config.callsEnabled) { if (!ServerConfig.callsEnabled) {
Logger.d(TAG, "call_signaling invite ignored (calls disabled in server config)") Logger.d(TAG, "call_signaling invite ignored (calls disabled in server config)")
return return
} }
@@ -122,7 +122,7 @@ object CallStore {
fun startOutgoingCall(peerUserId: Int) { fun startOutgoingCall(peerUserId: Int) {
if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return
if (!Config.callsEnabled) { if (!ServerConfig.callsEnabled) {
Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)") Logger.d(TAG, "startOutgoingCall ignored (calls disabled: set calls port in server settings)")
return return
} }
@@ -134,7 +134,7 @@ object CallStore {
val tok = ApiClient.fetchLiveKitToken(peerUserId, null) val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
// LiveKit WS endpoint is exposed on the same host as the server config, // LiveKit WS endpoint is exposed on the same host as the server config,
// using the configured calls port. // using the configured calls port.
val signalUrl = Config.liveKitWsUrl() val signalUrl = ServerConfig.liveKitWsUrl()
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl) ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl)
val label = peerLabel(peerUserId) val label = peerLabel(peerUserId)
LiveKitConnectSession( LiveKitConnectSession(
@@ -162,7 +162,7 @@ object CallStore {
fun acceptIncoming() { fun acceptIncoming() {
val inc = _ui.value as? CallUiState.Incoming ?: return val inc = _ui.value as? CallUiState.Incoming ?: return
if (!Config.callsEnabled) { if (!ServerConfig.callsEnabled) {
Logger.d(TAG, "acceptIncoming ignored (calls disabled)") Logger.d(TAG, "acceptIncoming ignored (calls disabled)")
return return
} }
@@ -175,7 +175,7 @@ object CallStore {
val display = val display =
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
LiveKitConnectSession( LiveKitConnectSession(
serverUrl = Config.liveKitWsUrl(), serverUrl = ServerConfig.liveKitWsUrl(),
token = tok.token, token = tok.token,
peerUserId = inc.fromUserId, peerUserId = inc.fromUserId,
peerDisplayName = display, peerDisplayName = display,
@@ -1,4 +1,4 @@
package ru.fromchat.calls package ru.fromchat.api.calls
data class LiveKitConnectSession( data class LiveKitConnectSession(
val serverUrl: String, val serverUrl: String,
@@ -1,4 +1,4 @@
package ru.fromchat.crypto package ru.fromchat.api.crypto
/** /**
* AES-GCM authentication failed or ciphertext is unrecoverable (wrong key, truncated, or tampered). * AES-GCM authentication failed or ciphertext is unrecoverable (wrong key, truncated, or tampered).
@@ -1,8 +1,12 @@
package ru.fromchat.crypto package ru.fromchat.api.crypto
import com.pr0gramm3r101.utils.crypto.PasswordHash import com.pr0gramm3r101.utils.crypto.PasswordHash
import ru.fromchat.api.DmEnvelope import com.pr0gramm3r101.utils.files.PlatformFileSystem
import ru.fromchat.crypto.dm.DmCrypto import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.api.crypto.dm.DmCrypto
/** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */ /** Shown in the UI when [DmCiphertextCorruptedException] is caught while decrypting a DM. */
/** Must match [ru.fromchat.Res.string.message_corrupted] (Compose resources). */ /** Must match [ru.fromchat.Res.string.message_corrupted] (Compose resources). */
@@ -55,7 +59,7 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String {
* Decrypt a DM file attachment to [outputPath]: streams download + decrypt without holding the full blob in RAM. * Decrypt a DM file attachment to [outputPath]: streams download + decrypt without holding the full blob in RAM.
*/ */
suspend fun decryptFileToPath( suspend fun decryptFileToPath(
file: ru.fromchat.api.DmFile, file: DmFile,
envelope: DmEnvelope, envelope: DmEnvelope,
currentUserId: Int?, currentUserId: Int?,
outputPath: String, outputPath: String,
@@ -71,8 +75,8 @@ suspend fun decryptFileToPath(
?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}") ?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}")
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}") Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
val encryptedOnDisk = ru.fromchat.api.ApiClient.fetchEncryptedFileResumable( val encryptedOnDisk = ApiClient.fetchEncryptedFileResumable(
path = file.path, path = file.path,
resumeKey = downloadResumeKey, resumeKey = downloadResumeKey,
onProgress = onDownloadProgress, onProgress = onDownloadProgress,
@@ -93,7 +97,7 @@ suspend fun decryptFileToPath(
decryptedSize decryptedSize
} finally { } finally {
if (downloadResumeKey == null) { if (downloadResumeKey == null) {
com.pr0gramm3r101.utils.files.PlatformFileSystem.delete(encryptedOnDisk.path) PlatformFileSystem.delete(encryptedOnDisk.path)
} }
} }
} }
@@ -1,4 +1,4 @@
package ru.fromchat.crypto package ru.fromchat.api.crypto
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import com.pr0gramm3r101.utils.settings.secureSettings import com.pr0gramm3r101.utils.settings.secureSettings
@@ -10,14 +10,15 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.PublicKeyResponse import ru.fromchat.api.schema.user.keys.PublicKeyResponse
import ru.fromchat.core.Logger import ru.fromchat.Logger
import ru.fromchat.core.config.Config import ru.fromchat.config.ServerConfig
import ru.fromchat.crypto.backup.BackupCrypto import ru.fromchat.api.crypto.backup.BackupCrypto
import ru.fromchat.crypto.backup.PrivateKeyBundle import ru.fromchat.api.crypto.backup.PrivateKeyBundle
import ru.fromchat.crypto.backup.decodeBlob import ru.fromchat.api.crypto.backup.decodeBlob
import ru.fromchat.crypto.backup.encodeBlob import ru.fromchat.api.crypto.backup.encodeBlob
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
/** /**
@@ -168,7 +169,7 @@ object IdentityKeyManager {
private suspend fun fetchBackupBlob(token: String): String? { private suspend fun fetchBackupBlob(token: String): String? {
return try { return try {
val response = ApiClient.http.get("${Config.apiBaseUrl}/crypto/backup") val response = ApiClient.http.get("${ServerConfig.apiBaseUrl}/crypto/backup")
val backupResponse = response.body<BackupBlobResponse>() val backupResponse = response.body<BackupBlobResponse>()
backupResponse.blob backupResponse.blob
} catch (e: Exception) { } catch (e: Exception) {
@@ -179,7 +180,7 @@ object IdentityKeyManager {
private suspend fun uploadBackupBlob(blobJson: String, token: String) { private suspend fun uploadBackupBlob(blobJson: String, token: String) {
val payload = BackupBlobRequest(blob = blobJson) val payload = BackupBlobRequest(blob = blobJson)
ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") { ApiClient.http.post("${ServerConfig.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(payload) setBody(payload)
} }
@@ -197,24 +198,24 @@ object IdentityKeyManager {
private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) { private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) {
val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey)) val payload = UploadPublicKeyRequest(publicKey = Base64.encode(publicKey))
ApiClient.http.post("${Config.apiBaseUrl}/crypto/public-key") { ApiClient.http.post("${ServerConfig.apiBaseUrl}/crypto/public-key") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(payload) setBody(payload)
} }
} }
} }
@kotlinx.serialization.Serializable @Serializable
private data class BackupBlobResponse( private data class BackupBlobResponse(
val blob: String? val blob: String?
) )
@kotlinx.serialization.Serializable @Serializable
private data class BackupBlobRequest( private data class BackupBlobRequest(
val blob: String val blob: String
) )
@kotlinx.serialization.Serializable @Serializable
private data class UploadPublicKeyRequest( private data class UploadPublicKeyRequest(
val publicKey: String val publicKey: String
) )
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.backup package ru.fromchat.api.crypto.backup
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -1,8 +1,8 @@
package ru.fromchat.crypto.dm package ru.fromchat.api.crypto.dm
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.crypto.DmCiphertextCorruptedException import ru.fromchat.api.crypto.DmCiphertextCorruptedException
/** /**
* Streams a MEK-encrypted attachment (ciphertext || tag on disk; IV passed separately) to [outputPath]. * Streams a MEK-encrypted attachment (ciphertext || tag on disk; IV passed separately) to [outputPath].
@@ -21,11 +21,7 @@ internal suspend fun aesGcmDecryptMekFileToPath(
outputPath = outputPath, outputPath = outputPath,
) )
} catch (e: Throwable) { } catch (e: Throwable) {
throw if (e is DmCiphertextCorruptedException) { throw e as? DmCiphertextCorruptedException ?: DmCiphertextCorruptedException(cause = e)
e
} else {
DmCiphertextCorruptedException(cause = e)
}
} }
} }
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.dm package ru.fromchat.api.crypto.dm
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.dm package ru.fromchat.api.crypto.dm
/** /**
* DM attachment file decrypt (streaming; shared across Android and iOS). * DM attachment file decrypt (streaming; shared across Android and iOS).
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
/** /**
* Result of client-side transport encryption for DMs. * Result of client-side transport encryption for DMs.
@@ -1,6 +1,6 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
import ru.fromchat.core.cache.openOutboundFileInputStream import ru.fromchat.api.local.cache.openOutboundFileInputStream
/** /**
* Encrypts a plaintext attachment file to a transport blob on disk without loading the full file into RAM. * Encrypts a plaintext attachment file to a transport blob on disk without loading the full file into RAM.
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
private const val TRANSPORT_FILE_KEY_CONTEXT = "fromchat_transport_file_v1" private const val TRANSPORT_FILE_KEY_CONTEXT = "fromchat_transport_file_v1"
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.transport package ru.fromchat.api.crypto.transport
/** /**
* Streaming transport file blob (AES-256-GCM, chunked). * Streaming transport file blob (AES-256-GCM, chunked).
@@ -1,13 +1,14 @@
package ru.fromchat.core.instance package ru.fromchat.api.instance
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfigData
import ru.fromchat.api.instance.InstanceIdGuard.INSTANCE_ID_HEADER
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import ru.fromchat.api.db.InstanceRegistryStore
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.cache.CacheContext
/** /**
* Handles [INSTANCE_ID_HEADER] on API responses (main client + probe during server setup). * Handles [INSTANCE_ID_HEADER] on API responses (main client + probe during server setup).
@@ -1,15 +1,14 @@
package ru.fromchat.core.instance package ru.fromchat.api.instance
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.network.sockets.ConnectTimeoutException import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.db.InstanceRegistryStore import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.core.ServerConfigData import ru.fromchat.config.ServerConfigData
import ru.fromchat.core.configKey
private val UUID_REGEX = private val UUID_REGEX =
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
@@ -1,4 +1,6 @@
package ru.fromchat.core package ru.fromchat.api.instance
import ru.fromchat.config.ServerConfigData
fun ServerConfigData.configKey(): String { fun ServerConfigData.configKey(): String {
val scheme = if (httpsEnabled) "1" else "0" val scheme = if (httpsEnabled) "1" else "0"
@@ -1,14 +1,13 @@
package ru.fromchat.core.instance package ru.fromchat.api.instance
import kotlin.time.TimeSource
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.db.InstanceRegistryStore import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.core.ServerConfigData import ru.fromchat.config.ServerConfigData
import ru.fromchat.core.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.core.config.Config import ru.fromchat.config.ServerConfig
import kotlin.time.TimeSource
sealed interface ServerProbeResult { sealed interface ServerProbeResult {
data class Supported( data class Supported(
@@ -64,7 +63,7 @@ suspend fun applyServerConfig(
callsOk: Boolean, callsOk: Boolean,
) { ) {
val tentative = config.copy(callsEnabled = callsOk) val tentative = config.copy(callsEnabled = callsOk)
Config.updateServerConfig(tentative) ServerConfig.updateServerConfig(tentative)
val userId = ApiClient.user?.id val userId = ApiClient.user?.id
InstanceRegistryStore.rebindServerInstance(tentative, instanceId) InstanceRegistryStore.rebindServerInstance(tentative, instanceId)
CacheContext.setActiveInstance(instanceId, userId) CacheContext.setActiveInstance(instanceId, userId)
@@ -1,4 +1,4 @@
package ru.fromchat.core.instance package ru.fromchat.api.instance
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -7,11 +7,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentDownloadNotifier import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.db.InstanceRegistryStore import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.core.Settings import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.outbox.scheduleOutboxProcessing import ru.fromchat.config.Settings
import ru.fromchat.core.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
sealed interface SessionBootstrapResult { sealed interface SessionBootstrapResult {
data object Ready : SessionBootstrapResult data object Ready : SessionBootstrapResult
@@ -1,7 +1,7 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local
import ru.fromchat.Logger
import kotlin.time.Clock import kotlin.time.Clock
import ru.fromchat.core.Logger
/** /**
* Unified filter tag for attachment media pipeline (upload, download, disk/bitmap cache, tile decode). * Unified filter tag for attachment media pipeline (upload, download, disk/bitmap cache, tile decode).
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local
fun mimeTypeForFilename(filename: String): String { fun mimeTypeForFilename(filename: String): String {
val ext = filename.substringAfterLast('.').lowercase() val ext = filename.substringAfterLast('.').lowercase()
@@ -1,4 +1,4 @@
package ru.fromchat.api package ru.fromchat.api.local
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.plugins.websocket.webSocket
@@ -11,29 +11,36 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.core.Logger import ru.fromchat.Logger
import ru.fromchat.core.cache.CacheContext import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.core.instance.InstanceIdGuard import ru.fromchat.api.instance.InstanceIdGuard
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
@@ -41,7 +48,6 @@ object WebSocketManager {
private const val TAG = "WebSocketManager" private const val TAG = "WebSocketManager"
private const val MIN_RECONNECT_DELAY_MS = 1_000L private const val MIN_RECONNECT_DELAY_MS = 1_000L
private const val MAX_RECONNECT_DELAY_MS = 60_000L private const val MAX_RECONNECT_DELAY_MS = 60_000L
/** Android [NetworkCallback.onAvailable] can fire repeatedly; avoid canceling the reconnect loop each time. */
private const val NETWORK_AVAILABLE_DEBOUNCE_MS = 2_000L private const val NETWORK_AVAILABLE_DEBOUNCE_MS = 2_000L
private const val FOREGROUND_DELAY_CHUNK_MS = 200L private const val FOREGROUND_DELAY_CHUNK_MS = 200L
@@ -91,10 +97,10 @@ object WebSocketManager {
private suspend fun delayWhileForeground(totalMs: Long) { private suspend fun delayWhileForeground(totalMs: Long) {
var remaining = totalMs var remaining = totalMs
while (remaining > 0) { while (remaining > 0) {
coroutineContext.ensureActive() currentCoroutineContext().ensureActive()
awaitForeground() awaitForeground()
val chunk = minOf(FOREGROUND_DELAY_CHUNK_MS, remaining) val chunk = minOf(FOREGROUND_DELAY_CHUNK_MS, remaining)
delay(chunk) delay(chunk.milliseconds)
remaining -= chunk remaining -= chunk
} }
} }
@@ -104,7 +110,7 @@ object WebSocketManager {
if (session != null) return true if (session != null) return true
val startTime = Clock.System.now().toEpochMilliseconds() val startTime = Clock.System.now().toEpochMilliseconds()
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) { while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
delay(100) delay(100.milliseconds)
} }
logD("waitForConnection finished: session=${session != null}") logD("waitForConnection finished: session=${session != null}")
return session != null return session != null
@@ -144,7 +150,7 @@ object WebSocketManager {
} }
try { try {
val wsUrl = Config.webSocketUrl val wsUrl = ServerConfig.webSocketUrl
logD("Attempting to connect to: $wsUrl") logD("Attempting to connect to: $wsUrl")
connecting = true connecting = true
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
@@ -194,7 +200,8 @@ object WebSocketManager {
val msg = when (messageType) { val msg = when (messageType) {
"updates" -> { "updates" -> {
runCatching { runCatching {
val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), jsonTree) val updatesData = json.decodeFromJsonElement(
WebSocketUpdatesData.serializer(), jsonTree)
UpdateSyncManager.onUpdatesBatch(updatesData.seq) UpdateSyncManager.onUpdatesBatch(updatesData.seq)
}.onFailure { }.onFailure {
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it) logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
@@ -277,8 +284,8 @@ object WebSocketManager {
} }
send(message) send(message)
withTimeout(timeoutMs) { withTimeout(timeoutMs.milliseconds) {
suspendCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
handler = { response -> handler = { response ->
if (response.type == message.type) { if (response.type == message.type) {
continuation.resumeWith(Result.success(response)) continuation.resumeWith(Result.success(response))
@@ -1,6 +1,6 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
/** Error key stored on [ru.fromchat.api.Message.uploadError] for localized UI. */ /** Error key stored on [ru.fromchat.api.schema.Message.uploadError] for localized UI. */
const val UPLOAD_ERROR_FILE_TOO_LARGE = "file_too_large" const val UPLOAD_ERROR_FILE_TOO_LARGE = "file_too_large"
/** Maximum plaintext attachment size (matches file_storage MAX_UPLOAD_SIZE). */ /** Maximum plaintext attachment size (matches file_storage MAX_UPLOAD_SIZE). */
@@ -1,12 +1,13 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.db.MessageDatabaseProvider import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.ui.chat.PublicChatPanelCache import ru.fromchat.api.local.cache.CacheContext.activeInstanceId
import ru.fromchat.ui.dm.DmPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
/** /**
* Active server instance partition for reads/writes. * Active server instance partition for reads/writes.
@@ -1,11 +1,11 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
import ru.fromchat.api.Message import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.db.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.db.conversationIdForGroup import ru.fromchat.api.local.messages.dmOtherUserIdFromConversationId
import ru.fromchat.api.db.dmOtherUserIdFromConversationId import ru.fromchat.api.local.messages.groupIdFromConversationId
import ru.fromchat.api.db.groupIdFromConversationId import ru.fromchat.api.schema.messages.Message
/** /**
* In-partition validation so stale rows from a wrong server switch cannot render as trusted history. * In-partition validation so stale rows from a wrong server switch cannot render as trusted history.
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.cache
import com.pr0gramm3r101.utils.files.PlatformFileSystem import com.pr0gramm3r101.utils.files.PlatformFileSystem
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -7,13 +7,17 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentDownloadNotifier import ru.fromchat.api.crypto.decryptFileToPath
import ru.fromchat.api.AttachmentDownloadProgress import ru.fromchat.api.crypto.dm.decryptFailureMessage
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.DmFile import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.crypto.dm.decryptFailureMessage import ru.fromchat.api.local.download.AttachmentDownloadProgress
import ru.fromchat.core.cache.copyOutboundFileToPath import ru.fromchat.api.local.download.AttachmentDownloadScheduler
import ru.fromchat.crypto.decryptFileToPath import ru.fromchat.api.local.download.checkAttachmentDownloadActive
import ru.fromchat.api.local.download.ensureAttachmentDownloadActive
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.api.local.download.DownloadedFileRegistry
/** /**
* Disk cache for decrypted non-image DM attachments (bytes on disk, opened via platform URI). * Disk cache for decrypted non-image DM attachments (bytes on disk, opened via platform URI).
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat package ru.fromchat.api.local.cache
import com.pr0gramm3r101.utils.files.PlatformFileSystem import com.pr0gramm3r101.utils.files.PlatformFileSystem
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -7,11 +7,18 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.DmFile import ru.fromchat.api.local.download.AttachmentDownloadProgress
import ru.fromchat.api.AttachmentDownloadNotifier import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.AttachmentDownloadProgress import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.crypto.decryptFileToPath import ru.fromchat.api.crypto.decryptFileToPath
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.download.AttachmentDownloadScheduler
import ru.fromchat.api.local.download.checkAttachmentDownloadActive
import ru.fromchat.api.local.download.ensureAttachmentDownloadActive
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
import ru.fromchat.api.local.cache.DecryptedImageCache.storageKey
import ru.fromchat.api.local.download.LocalDecodedImageCache
/** /**
* Disk + in-memory cache for decrypted DM images. * Disk + in-memory cache for decrypted DM images.
@@ -112,7 +119,7 @@ object DecryptedImageCache {
val cidKey = storageKey(messageId, fileIndex, cid) val cidKey = storageKey(messageId, fileIndex, cid)
val sourceUri = readDisk(cidKey) ?: return val sourceUri = readDisk(cidKey) ?: return
val bytes = runCatching { val bytes = runCatching {
ru.fromchat.core.cache.readOutboundFileBytes(sourceUri) readOutboundFileBytes(sourceUri)
}.getOrNull() ?: return }.getOrNull() ?: return
if (bytes.isEmpty()) return if (bytes.isEmpty()) return
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -286,7 +293,7 @@ object DecryptedImageCache {
} }
val t0 = AttachmentMediaLog.nowMs() val t0 = AttachmentMediaLog.nowMs()
val bytes = runCatching { val bytes = runCatching {
ru.fromchat.core.cache.readOutboundFileBytes(localFileUri) readOutboundFileBytes(localFileUri)
}.getOrNull() }.getOrNull()
if (bytes == null) { if (bytes == null) {
AttachmentMediaLog.diskCache("seed_read_failed", "key" to key, "src" to localFileUri) AttachmentMediaLog.diskCache("seed_read_failed", "key" to key, "src" to localFileUri)
@@ -1,4 +1,4 @@
package ru.fromchat.core.files package ru.fromchat.api.local.cache
/** /**
* Buffered file writer for streaming HTTP downloads (single open, chunked writes). * Buffered file writer for streaming HTTP downloads (single open, chunked writes).
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
/** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */ /** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */
expect suspend fun wipeFromChatCacheDirectory() expect suspend fun wipeFromChatCacheDirectory()
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
/** /**
* Detects OS "clear cache" (generation sentinel missing) and records a new generation after open. * Detects OS "clear cache" (generation sentinel missing) and records a new generation after open.
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache package ru.fromchat.api.local.cache
/** Original attachment URI is no longer readable (revoked permission, deleted file, etc.). */ /** Original attachment URI is no longer readable (revoked permission, deleted file, etc.). */
class OutboundFileUnavailableException( class OutboundFileUnavailableException(

Some files were not shown because too many files have changed in this diff Show More