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>
<provider
android:name="ru.fromchat.core.files.AttachmentFileProvider"
android:name="ru.fromchat.api.local.cache.AttachmentFileProvider"
android:authorities="${applicationId}.attachment_files"
android:exported="false"
android:grantUriPermissions="true">
@@ -10,8 +10,8 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentTransferBootstrap
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.notifications.NotificationHelper
class App: Application() {
@@ -13,7 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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.GoogleApiAvailability
import io.ktor.client.call.body
@@ -25,13 +25,11 @@ import io.ktor.http.contentType
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import ru.fromchat.api.ApiClient
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.core.Logger
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.config.ServerConfig
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_OPEN_DM_USER_ID = "open_dm_user_id"
@@ -172,13 +170,13 @@ class MainActivity : ComponentActivity() {
try {
// Get all unread messages and mark them as read
val messageIds = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
.map { it.id }
if (messageIds.isNotEmpty()) {
ApiClient.http.post("${Config.apiBaseUrl}/messages/read") {
ApiClient.http.post("${ServerConfig.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json)
setBody(mapOf("messageIds" to messageIds))
}
@@ -10,7 +10,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
@OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
@@ -27,14 +27,15 @@ import kotlinx.coroutines.launch
import ru.fromchat.MainActivity
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmHistoryResponse
import ru.fromchat.api.Message
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.isPublicChatVisible
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
import kotlin.time.Instant
object NotificationHelper {
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
@@ -141,7 +142,7 @@ object NotificationHelper {
}
val messages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.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")
ApiClient.loadPersistedData()
val retryMessages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new")
.get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
Log.d(
@@ -470,7 +471,7 @@ object NotificationHelper {
).setConversationTitle("Public Chat").let {
for (msg in messages.takeLast(10)) {
val timestamp = try {
java.time.Instant.parse(msg.timestamp).toEpochMilli()
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
+1 -1
View File
@@ -11,7 +11,7 @@ kotlin {
android {
namespace = "ru.fromchat.shared"
minSdk = 24
compileSdk = 36
compileSdk = 37
}
compilerOptions {
@@ -9,15 +9,15 @@
<application>
<service
android:name="ru.fromchat.calls.CallForegroundService"
android:name="ru.fromchat.ui.calls.CallForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<service
android:name="ru.fromchat.download.AttachmentDownloadForegroundService"
android:name="ru.fromchat.api.local.workers.AttachmentDownloadForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="ru.fromchat.download.AttachmentFileCopyForegroundService"
android:name="ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
@@ -1,4 +1,4 @@
package ru.fromchat.core
package ru.fromchat
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 com.google.android.gms.tasks.Task
import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings
import com.google.android.gms.tasks.Task
import io.ktor.client.call.body
import io.ktor.client.request.header
import io.ktor.client.request.post
@@ -11,9 +11,8 @@ import io.ktor.client.request.setBody
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import ru.fromchat.api.SimpleStatusResponse
import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.config.ServerConfig
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -37,7 +36,7 @@ private suspend fun postFcmToken(token: String): Boolean {
return runCatching {
val suffix = token.takeLast(8)
ApiClient.http
.post("${Config.apiBaseUrl}/push/register") {
.post("${ServerConfig.apiBaseUrl}/push/register") {
header("Content-Type", "application/json")
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()
Log.d("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
return@withContext runCatching {
ApiClient.http.post("${Config.apiBaseUrl}/push/unregister") {
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
header("Content-Type", "application/json")
if (token.isNotEmpty()) {
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.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 javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
@@ -34,7 +38,7 @@ actual object BackupCrypto {
val parameterSpec = GCMParameterSpec(128, nonce)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec)
val ciphertext = cipher.doFinal(serialized)
EncryptedBackupBlob(salt, nonce, ciphertext)
}
@@ -1,10 +1,10 @@
package ru.fromchat.crypto.dm
package ru.fromchat.api.crypto.dm
import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.backup.BackupCryptoPlatform
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
import ru.fromchat.api.crypto.backup.BackupCryptoPlatform
import java.security.GeneralSecurityException
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.modes.GCMBlockCipher
@@ -6,6 +6,7 @@ import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.params.KeyParameter
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
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(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
val inputFile = java.io.File(encryptedPath)
val outputFile = java.io.File(outputPath)
val inputFile = File(encryptedPath)
val outputFile = File(outputPath)
outputFile.parentFile?.mkdirs()
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.crypto.transport.TransportCiphertext
import java.security.SecureRandom
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.FileOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.crypto.transport.encryptPlaintextFileToFcaeBlob
actual object TransportFileEncryptor {
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.box.Box
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.spec.SecretKeySpec
@OptIn(ExperimentalUnsignedTypes::class)
internal actual fun deriveTransportFileAesKey(
transportPublicKeyB64: String,
ephemeralSecretKey: ByteArray,
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat
package ru.fromchat.api.local.cache
import android.content.Context
import androidx.work.CoroutineWorker
@@ -8,6 +8,8 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.pr0gramm3r101.utils.UtilsLibrary
import ru.fromchat.api.local.download.cachedAttachmentFileSize
import ru.fromchat.ui.chat.copyCachedFileToDestinationUri
class AttachmentFileCopyWorker(
appContext: Context,
@@ -52,8 +54,4 @@ 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.database.Cursor
@@ -85,14 +85,14 @@ class AttachmentFileProvider : FileProvider() {
companion object {
fun uriForFile(context: Context, file: File): Uri? = runCatching {
FileProvider.getUriForFile(
getUriForFile(
context,
"${context.packageName}.attachment_files",
file,
)
}.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 =
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 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.File
@@ -1,4 +1,4 @@
package ru.fromchat.core.cache
package ru.fromchat.api.local.cache
import com.pr0gramm3r101.utils.UtilsLibrary
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 java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.db.MessageDatabaseProvider
import ru.fromchat.core.cache.wipeFromChatCacheDirectory
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import java.io.File
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.content.res.AssetFileDescriptor
@@ -7,6 +7,7 @@ import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
private fun uploadDir(instanceId: String): File {
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
@@ -202,7 +203,7 @@ actual suspend fun repairInterruptedUploadArtifacts(
}
private class AndroidOutboundFileInputStream(
private val input: java.io.InputStream,
private val input: InputStream,
) : OutboundFileInputStream {
override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int =
withContext(Dispatchers.IO) {
@@ -1,4 +1,4 @@
package ru.fromchat.api.db
package ru.fromchat.api.local.db
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.driver.android.AndroidSqliteDriver
import com.pr0gramm3r101.utils.UtilsLibrary
import androidx.sqlite.db.SupportSQLiteDatabase
import java.io.File
import ru.fromchat.db.MessageDatabase
import java.io.File
actual fun provideMessageDatabaseDriver(): SqlDriver {
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) {
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 ru.fromchat.download.AttachmentDownloadForegroundService
import ru.fromchat.api.local.workers.AttachmentDownloadForegroundService
actual object AttachmentDownloadForeground {
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.content.ClipData
@@ -10,8 +10,10 @@ import android.os.Build
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.core.Logger
import ru.fromchat.core.files.AttachmentFileProvider
import ru.fromchat.Logger
import ru.fromchat.api.local.cache.AttachmentFileProvider
import ru.fromchat.api.local.mimeTypeForFilename
import ru.fromchat.ui.chat.uriToLocalCacheFile
actual suspend fun openCachedAttachmentFile(
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.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.BitmapFactory
@@ -6,6 +6,7 @@ import android.graphics.Matrix
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
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? {
val orientation = readExifOrientation(path)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation)
val sampleSize = calculateInSampleSize(orientedW, orientedH, reqWidthPx, reqHeightPx)
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val decoded = BitmapFactory.decodeFile(path, options) ?: return null
val oriented = applyExifOrientation(decoded, orientation)
return scaleBitmapToFitWithin(oriented, reqWidthPx, reqHeightPx)
return scaleBitmapToFitWithin(
applyExifOrientation(
BitmapFactory.decodeFile(
path,
BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(
orientedW,
orientedH,
reqWidthPx,
reqHeightPx
)
inPreferredConfig = Bitmap.Config.ARGB_8888
}
) ?: return null,
orientation
),
reqWidthPx,
reqHeightPx
)
}
private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val sampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, reqWidthPx, reqHeightPx)
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
return scaleBitmapToFitWithin(decoded, reqWidthPx, reqHeightPx)
return scaleBitmapToFitWithin(
BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(
bounds.outWidth,
bounds.outHeight,
reqWidthPx,
reqHeightPx
)
inPreferredConfig = Bitmap.Config.ARGB_8888
}
) ?: return null,
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 {
if (bitmap.width <= reqWidthPx && bitmap.height <= reqHeightPx) return bitmap
val scale = minOf(
reqWidthPx.toFloat() / bitmap.width.toFloat(),
reqHeightPx.toFloat() / bitmap.height.toFloat(),
)
if (scale >= 1f) return bitmap
val dstW = (bitmap.width * scale).toInt().coerceAtLeast(1)
val dstH = (bitmap.height * scale).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(bitmap, dstW, dstH, true)
return bitmap.scale(
(bitmap.width * scale).toInt().coerceAtLeast(1),
(bitmap.height * scale).toInt().coerceAtLeast(1)
)
}
private fun readExifOrientation(path: String): Int =
private fun readExifOrientation(path: String) =
runCatching {
ExifInterface(path).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
@@ -98,13 +130,13 @@ private fun orientedDimensions(width: Int, height: Int, orientation: Int): Pair<
ExifInterface.ORIENTATION_ROTATE_90,
ExifInterface.ORIENTATION_ROTATE_270,
ExifInterface.ORIENTATION_TRANSPOSE,
ExifInterface.ORIENTATION_TRANSVERSE,
-> height to width
ExifInterface.ORIENTATION_TRANSVERSE -> height to width
else -> width to height
}
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
@@ -121,8 +153,16 @@ private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): 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. */
@@ -133,12 +173,15 @@ private fun calculateInSampleSize(
reqHeightPx: Int,
): Int {
var inSampleSize = 1
if (height > reqHeightPx || width > reqWidthPx) {
var halfHeight = height / 2
var halfWidth = width / 2
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeightPx && halfWidth / inSampleSize >= reqWidthPx) {
inSampleSize *= 2
}
}
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.content.Context
@@ -16,6 +16,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.pr0gramm3r101.utils.UtilsLibrary
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?>() {
override fun createIntent(context: Context, input: SavableMessageFile): Intent {
@@ -54,17 +57,21 @@ actual fun rememberPlatformSaveMessageFile(
val launcher = rememberLauncherForActivityResult(CreateFileSaveContract()) { destination ->
val pending = pendingSavable
pendingSavable = null
if (destination == null || pending == null) {
onComplete(false)
return@rememberLauncherForActivityResult
}
scope.launch {
runCatching {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
UtilsLibrary.context.contentResolver.takePersistableUriPermission(destination, flags)
UtilsLibrary.context.contentResolver.takePersistableUriPermission(
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(
PendingFileSaveEntry(
storageKey = pending.storageKey,
@@ -77,12 +84,14 @@ actual fun rememberPlatformSaveMessageFile(
onComplete(false)
return@launch
}
val ok = copyCachedFileToDestinationUri(
sourceCacheUri = cacheUri,
sourceCacheUri = pending.cacheUri,
destinationUri = destination.toString(),
storageKey = pending.storageKey,
displayFilename = pending.filename,
)
if (ok) {
PendingFileSaveRegistry.remove(pending.storageKey)
} else {
@@ -96,9 +105,11 @@ actual fun rememberPlatformSaveMessageFile(
),
)
}
onComplete(ok)
}
}
return remember(launcher) {
{ savable: SavableMessageFile ->
pendingSavable = savable
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat
package ru.fromchat.api.local.download
import android.app.Activity
import android.content.Context
@@ -59,15 +59,17 @@ actual fun rememberPlatformSaveMessageImage(
onComplete(false)
return@rememberLauncherForActivityResult
}
scope.launch {
val ok = withContext(Dispatchers.IO) {
runCatching {
UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out ->
out.write(bytes)
} != null
}.getOrDefault(false)
}
onComplete(ok)
onComplete(
withContext(Dispatchers.IO) {
runCatching {
UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out ->
out.write(bytes)
} != null
}.getOrDefault(false)
}
)
}
}
@@ -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.NotificationChannel
import android.app.NotificationManager
@@ -47,7 +48,7 @@ object MediaUploadForegroundHelper {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setSmallIcon(R.drawable.stat_sys_upload)
.setOngoing(true)
.setOnlyAlertOnce(true)
.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.Data
@@ -1,13 +1,13 @@
package ru.fromchat.api.outbox
package ru.fromchat.api.local.send
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.AttachmentUploadNotifier
import ru.fromchat.api.AttachmentUploadProgress
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
import ru.fromchat.api.local.workers.AttachmentUploadProgress
import ru.fromchat.api.local.cache.CacheContext
class OutboxSendWorker(
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.NotificationChannel
import android.app.NotificationManager
@@ -104,7 +105,7 @@ class AttachmentDownloadForegroundService : Service() {
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setSmallIcon(R.drawable.stat_sys_download)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
@@ -127,9 +128,9 @@ class AttachmentDownloadForegroundService : Service() {
private const val CHANNEL_ID = "fromchat_file_download"
private const val NOTIFICATION_ID = 0xFC12
private const val ACTION_START = "ru.fromchat.download.AttachmentDownloadForegroundService.START"
private const val ACTION_STOP = "ru.fromchat.download.AttachmentDownloadForegroundService.STOP"
private const val ACTION_UPDATE = "ru.fromchat.download.AttachmentDownloadForegroundService.UPDATE"
private const val ACTION_START = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.START"
private const val ACTION_STOP = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.STOP"
private const val ACTION_UPDATE = "ru.fromchat.api.local.workers.AttachmentDownloadForegroundService.UPDATE"
private const val EXTRA_CHANNEL_NAME = "channel_name"
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 ru.fromchat.download.AttachmentFileCopyForegroundService
actual object AttachmentFileCopyForeground {
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.NotificationChannel
import android.app.NotificationManager
@@ -51,7 +52,7 @@ class AttachmentFileCopyForegroundService : Service() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setSmallIcon(R.drawable.stat_sys_download_done)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
@@ -81,9 +82,9 @@ class AttachmentFileCopyForegroundService : Service() {
private const val NOTIFICATION_ID = 0xFC13
private const val ACTION_START =
"ru.fromchat.download.AttachmentFileCopyForegroundService.START"
"ru.fromchat.api.local.workers.AttachmentFileCopyForegroundService.START"
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_TITLE = "title"
@@ -1,11 +1,12 @@
package ru.fromchat.api
package ru.fromchat.api.local.workers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import ru.fromchat.api.outbox.OutgoingMessageCoordinator
import ru.fromchat.api.outbox.scheduleOutboxProcessing
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext
actual object AttachmentUploadQueue {
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
@@ -18,7 +19,7 @@ actual object AttachmentUploadQueue {
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isEmpty()) return
MainScope().launch {
val row = ru.fromchat.api.db.MessageDatabaseProvider.database.messageDatabaseQueries
val row = MessageDatabaseProvider.database.messageDatabaseQueries
.selectOutboxItem(instanceId, jobId.trim())
.executeAsOneOrNull()
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.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.graphics.Color
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Color
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore
/**
* Foreground call session: keeps camera / mic eligible in background.
@@ -34,7 +36,7 @@ class CallForegroundService : Service() {
stopSelf()
return START_NOT_STICKY
}
ACTION_START -> startAsForeground(intent!!)
ACTION_START -> startAsForeground(intent)
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -61,7 +63,7 @@ class CallForegroundService : Service() {
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
android.R.drawable.sym_call_outgoing
R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService(
@@ -1,6 +1,7 @@
package ru.fromchat.calls
package ru.fromchat.ui.calls
import android.Manifest
import android.R
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
@@ -19,8 +20,8 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
@@ -64,25 +63,24 @@ import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
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.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -100,21 +100,19 @@ import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.livekit.android.RoomOptions
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.rememberParticipants
import io.livekit.android.compose.types.TrackReference
import io.livekit.android.compose.ui.ScaleType
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.track.LocalAudioTrackOptions
import io.livekit.android.room.participant.LocalParticipant
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.screencapture.ScreenCaptureParams
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -123,14 +121,29 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.core.Logger
import ru.fromchat.api.calls.CallStore
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 kotlin.math.roundToInt
import kotlin.math.sqrt
private const val TAG = "CallMediaLayer"
private const val SCREEN_SHARE_NOTIFICATION_ID = 99102
@@ -458,10 +471,9 @@ actual fun CallMediaLayer(
}
is RoomEvent.FailedToConnect -> {
val msg =
event.error?.message
?.takeIf { it.isNotBlank() }
?: event.error?.toString()
.orEmpty()
event.error.message?.takeIf { it.isNotBlank() }
?: event.error.toString()
Logger.e(TAG, "RoomEvent.FailedToConnect", event.error)
scheduleReconnect(msg.takeIf { it.isNotBlank() })
}
@@ -1159,7 +1171,7 @@ private fun CallPreviewCluster(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class)
@Composable
private fun PreviewTile(
room: Room,
@@ -1232,7 +1244,7 @@ private fun CallInlineControlBar(
room: Room,
micRequestedOn: Boolean,
onMicRequestedChange: (Boolean) -> Unit,
hazeState: dev.chrisbanes.haze.HazeState,
hazeState: HazeState,
modifier: Modifier,
) {
val scope = rememberCoroutineScope()
@@ -1277,7 +1289,7 @@ private fun CallInlineControlBar(
val smallIcon = try {
context.packageManager.getApplicationInfo(context.packageName, 0).icon
} catch (_: Exception) {
android.R.drawable.stat_sys_upload
R.drawable.stat_sys_upload
}
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle)
@@ -1,3 +1,3 @@
package ru.fromchat.calls
package ru.fromchat.ui.calls
actual val UseInlineInCallChrome: Boolean = true
@@ -4,7 +4,7 @@ import android.net.Uri
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.AttachmentFileCopyForeground
import ru.fromchat.api.local.workers.AttachmentFileCopyForeground
import java.io.File
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.content.Context
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat
package ru.fromchat.ui.chat.utils
import android.widget.Toast
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 androidx.activity.compose.BackHandler as AndroidBackHandler
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
AndroidBackHandler(enabled = enabled, onBack = onBack)
}
@SuppressLint("InlinedApi")
@Composable
actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
val view = LocalView.current
@@ -1,8 +0,0 @@
package ru.fromchat.ui.debug
import androidx.compose.runtime.Composable
@Composable
actual fun DebugApiScreen() {
DebugApiScreenContent()
}
@@ -1,43 +1,43 @@
package ru.fromchat.platform
package ru.fromchat.ui.main.settings
import android.content.Intent
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import android.provider.Settings
import com.pr0gramm3r101.utils.UtilsLibrary.context
import com.pr0gramm3r101.utils.UtilsLibrary
actual fun openAppNotificationSettings(): Boolean =
try {
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_APP_PACKAGE, UtilsLibrary.context.packageName)
}
} else {
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)
context.startActivity(intent)
UtilsLibrary.context.startActivity(intent)
true
} catch (_: Exception) {
false
}
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) {
ContextCompat.checkSelfPermission(
context,
UtilsLibrary.context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
}
@@ -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,9 +1,8 @@
package ru.fromchat
package ru.fromchat.ui.profile
import android.widget.Toast
import com.pr0gramm3r101.utils.UtilsLibrary
actual fun showProfileLoadErrorMessage(message: String) {
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.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import androidx.annotation.RequiresPermission
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.local.WebSocketManager
actual object NetworkConnectivity {
private val _isOnline = MutableStateFlow(true)
@@ -17,6 +19,7 @@ actual object NetworkConnectivity {
private var callback: ConnectivityManager.NetworkCallback? = null
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
@Suppress("DEPRECATION")
private fun computeOnline(cm: ConnectivityManager): Boolean {
val n = cm.activeNetwork ?: return false
@@ -24,6 +27,7 @@ actual object NetworkConnectivity {
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
actual fun ensureStarted() {
if (callback != null) return
val context = UtilsLibrary.context
@@ -213,12 +213,11 @@
<string name="settings_notifications_title">Уведомления</string>
<string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</string>
<string name="settings_open_notification_settings">Открыть настройки уведомлений</string>
<string name="settings_notifications_enable">Включить push-уведомления</string>
<string name="settings_notifications_disable">Отключить push-уведомления</string>
<string name="settings_notification_settings">Настройки уведомлений</string>
<string name="settings_notification_settings_d">Здесь вы можете включить или выключить типы уведомлений, изменить звук и т.д.</string>
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
<string name="settings_push_notifications_enabled">Push-уведомления включены</string>
<string name="settings_push_notifications_disabled">Push-уведомления отключены</string>
<string name="settings_push_notifications">Push-уведомления</string>
<string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
<string name="settings_devices_title">Устройства</string>
<string name="settings_devices_empty">Нет активных сессий</string>
@@ -245,6 +244,8 @@
<string name="settings_devices_logout_all">Выйти на всех других устройствах</string>
<string name="settings_devices_logout_all_confirm_title">Выйти везде, кроме этого?</string>
<string name="settings_devices_logout_all_confirm_body">Вы останетесь в аккаунте только на этом устройстве.</string>
<string name="settings_devices_active_sessions">Активные сеансы</string>
<string name="confirm">Подтвердить</string>
<string name="cancel">Отмена</string>
@@ -281,6 +282,7 @@
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
<string name="more">Ещё</string>
<string name="unread_count">+%1$d</string>
<string name="unknown">Неизвестно</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_hint">192.168.1.10</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_hint"></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_headline">HTTPS</string>
@@ -243,13 +241,11 @@
<string name="settings_category_account_d">Log out or delete account</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_open_notification_settings">Open notification settings</string>
<string name="settings_notifications_enable">Enable push notifications</string>
<string name="settings_notifications_disable">Disable push notifications</string>
<string name="settings_notification_settings">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_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_disabled">Push notifications disabled</string>
<string name="settings_push_notifications">Push notifications</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_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_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_active_sessions">Active sessions</string>
<string name="confirm">Confirm</string>
<string name="cancel">Cancel</string>
@@ -308,6 +305,7 @@
<string name="error_invalid_credentials">Wrong username or password</string>
<string name="error_connection">Couldnt connect. Check your internet.</string>
<string name="error_unknown">Something went wrong. Please try again.</string>
<string name="unknown">Unknown</string>
<!-- Typing Indicators -->
<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.asStateFlow
/**
* Mirrors process visibility: [setForeground] from [androidx.lifecycle.Lifecycle.Event.ON_START] /
* [androidx.lifecycle.Lifecycle.Event.ON_STOP] (or initial [syncFromLifecycle]).
*/
object AppForeground {
private val _isInForeground = MutableStateFlow(true)
val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow()
@@ -1,9 +1,8 @@
package ru.fromchat.core
package ru.fromchat
expect object Logger {
fun d(tag: String, message: String, throwable: Throwable? = null)
fun i(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)
}
}
@@ -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
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.settings
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.Logging
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.pingInterval
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import io.ktor.client.request.patch
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.Settings
import ru.fromchat.core.config.Config
import ru.fromchat.core.instance.InstanceIdGuard
import ru.fromchat.api.db.InstanceRegistryStore
import ru.fromchat.ui.chat.PublicChatPanelCache
import ru.fromchat.ui.dm.DmPanelCache
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import ru.fromchat.api.ApiClient.logout
import ru.fromchat.api.ApiClient.persistSessionToStorage
import ru.fromchat.api.crypto.IdentityKeyManager
import ru.fromchat.api.crypto.transport.TransportCiphertext
import ru.fromchat.api.crypto.transport.TransportCrypto
import ru.fromchat.api.instance.InstanceIdGuard
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.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
@@ -157,7 +204,7 @@ object ApiClient {
validateResponse { response ->
val instanceHeader = response.headers[InstanceIdGuard.INSTANCE_ID_HEADER]
runCatching {
Config.serverConfig.value?.let { cfg ->
ServerConfig.serverConfig.value?.let { cfg ->
InstanceIdGuard.onResponseHeader(instanceHeader, cfg)
}
}
@@ -244,7 +291,7 @@ object ApiClient {
suspend fun refreshServerInstanceFingerprint() {
runCatching {
val id = fetchServerInstanceId(Config.apiBaseUrl)
val id = fetchServerInstanceId(ServerConfig.apiBaseUrl)
if (id.isNotEmpty()) {
Settings.lastKnownServerInstanceId = id
InstanceRegistryStore.registerInstanceEncountered(id)
@@ -315,14 +362,14 @@ object ApiClient {
}
}
} 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 =
http
.post("${Config.apiBaseUrl}/login") {
.post("${ServerConfig.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -330,7 +377,7 @@ object ApiClient {
suspend fun registerRequest(request: RegisterRequest): LoginResponse =
http
.post("${Config.apiBaseUrl}/register") {
.post("${ServerConfig.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -365,7 +412,7 @@ object ApiClient {
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http
.get("${Config.apiBaseUrl}/get_messages") {
.get("${ServerConfig.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json)
parameter("limit", limit)
beforeId?.let { parameter("before_id", it) }
@@ -374,28 +421,28 @@ object ApiClient {
suspend fun getOwnProfile(): UserProfile =
http
.get("${Config.apiBaseUrl}/user/profile") {
.get("${ServerConfig.apiBaseUrl}/user/profile") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getProfileById(userId: Int): UserProfile =
http
.get("${Config.apiBaseUrl}/user/id/$userId") {
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getProfileByUsername(username: String): UserProfile =
http
.get("${Config.apiBaseUrl}/user/$username") {
.get("${ServerConfig.apiBaseUrl}/user/$username") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getRegisteredUserCount(): Int =
http
.get("${Config.apiBaseUrl}/user/stats/registered-count") {
.get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") {
contentType(ContentType.Application.Json)
}
.body<RegisteredUserCountResponse>()
@@ -404,7 +451,7 @@ object ApiClient {
suspend fun checkSimilarity(userId: Int): SimilarityResult? =
runCatching {
http
.get("${Config.apiBaseUrl}/user/check-similarity/$userId") {
.get("${ServerConfig.apiBaseUrl}/user/check-similarity/$userId") {
contentType(ContentType.Application.Json)
}
.body<SimilarityResult>()
@@ -413,7 +460,7 @@ object ApiClient {
suspend fun verifyUser(userId: Int): VerifyResponse? =
runCatching {
http
.post("${Config.apiBaseUrl}/user/$userId/verify") {
.post("${ServerConfig.apiBaseUrl}/user/$userId/verify") {
contentType(ContentType.Application.Json)
}
.body<VerifyResponse>()
@@ -421,7 +468,7 @@ object ApiClient {
suspend fun getDmConversations(): List<DmConversation> =
http
.get("${Config.apiBaseUrl}/dm/conversations") {
.get("${ServerConfig.apiBaseUrl}/dm/conversations") {
contentType(ContentType.Application.Json)
}
.body<DmConversationsResponse>()
@@ -429,7 +476,7 @@ object ApiClient {
suspend fun getDmFetch(since: Int? = null): DmHistoryResponse {
return http
.get("${Config.apiBaseUrl}/dm/fetch") {
.get("${ServerConfig.apiBaseUrl}/dm/fetch") {
contentType(ContentType.Application.Json)
since?.let { parameter("since", it) }
}
@@ -442,7 +489,7 @@ object ApiClient {
beforeId: Int? = null
): DmHistoryResponse =
http
.get("${Config.apiBaseUrl}/dm/history/$otherUserId") {
.get("${ServerConfig.apiBaseUrl}/dm/history/$otherUserId") {
contentType(ContentType.Application.Json)
parameter("limit", limit)
beforeId?.let { parameter("before_id", it) }
@@ -451,21 +498,21 @@ object ApiClient {
suspend fun getOwnPublicKey(): PublicKeyResponse =
http
.get("${Config.apiBaseUrl}/crypto/public-key") {
.get("${ServerConfig.apiBaseUrl}/crypto/public-key") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getUserPublicKey(userId: Int): PublicKeyResponse =
http
.get("${Config.apiBaseUrl}/crypto/public-key/of/$userId") {
.get("${ServerConfig.apiBaseUrl}/crypto/public-key/of/$userId") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getTransportPublicKey(): TransportKeyResponse =
http
.get("${Config.apiBaseUrl}/dm/key/transport/public") {
.get("${ServerConfig.apiBaseUrl}/dm/key/transport/public") {
contentType(ContentType.Application.Json)
}
.body()
@@ -512,7 +559,7 @@ object ApiClient {
uploadedFileIds = uploadedFileIds
)
http.post("${Config.apiBaseUrl}/dm/send") {
http.post("${ServerConfig.apiBaseUrl}/dm/send") {
contentType(ContentType.Application.Json)
setBody(body)
}
@@ -524,7 +571,7 @@ object ApiClient {
recipientId: Int,
chunkSize: Int? = null
): DmUploadInitResponse =
http.post("${Config.apiBaseUrl}/dm/upload/init") {
http.post("${ServerConfig.apiBaseUrl}/dm/upload/init") {
contentType(ContentType.Application.Json)
setBody(
DmUploadInitRequest(
@@ -537,7 +584,7 @@ object ApiClient {
}.body()
suspend fun getDmUploadStatus(uploadId: String): DmUploadStatusResponse =
http.get("${Config.apiBaseUrl}/dm/upload/$uploadId") {
http.get("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") {
contentType(ContentType.Application.Json)
}.body()
@@ -546,7 +593,7 @@ object ApiClient {
offset: Long,
dataB64: String
): DmUploadChunkResponse =
http.patch("${Config.apiBaseUrl}/dm/upload/$uploadId") {
http.patch("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") {
contentType(ContentType.Application.Json)
setBody(
DmUploadChunkRequest(
@@ -557,13 +604,13 @@ object ApiClient {
}.body()
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)
setBody(mapOf("upload_id" to uploadId))
}.body()
suspend fun abortDmUpload(uploadId: String) {
http.delete("${Config.apiBaseUrl}/dm/upload/$uploadId") {
http.delete("${ServerConfig.apiBaseUrl}/dm/upload/$uploadId") {
contentType(ContentType.Application.Json)
}
}
@@ -575,10 +622,10 @@ object ApiClient {
fun encryptedFileUrl(path: String): String = when {
path.startsWith("http") -> path
path.startsWith("/api") -> {
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
val serverBase = ServerConfig.apiBaseUrl.removeSuffix("/api")
"$serverBase$path"
}
else -> "${Config.apiBaseUrl}$path"
else -> "${ServerConfig.apiBaseUrl}$path"
}
/** Encrypted ciphertext stored on disk after a resumable download. */
@@ -659,7 +706,7 @@ object ApiClient {
return EncryptedFileOnDisk(outputPath, received)
}
private fun currentDownloadUserAgent(): String? {
private fun currentDownloadUserAgent(): String {
val currentDevice = currentDeviceInfo()
return buildLoginUserAgent(
osName = currentDevice.osName?.takeIf { it.isNotBlank() },
@@ -886,7 +933,7 @@ object ApiClient {
private suspend fun readPartialDownloadMetaFileFromDisk(path: String): PartialDownloadMeta? {
if (!PlatformFileSystem.exists(path)) return null
val text = runCatching {
ru.fromchat.core.cache.readOutboundFileBytes("file://$path").decodeToString()
readOutboundFileBytes("file://$path").decodeToString()
}.getOrNull() ?: return null
return parsePartialDownloadMeta(text)
}
@@ -946,7 +993,7 @@ object ApiClient {
val path = pausedDownloadIndexPath() ?: return emptySet()
if (!PlatformFileSystem.exists(path)) return emptySet()
return runCatching {
ru.fromchat.core.cache.readOutboundFileBytes("file://$path")
readOutboundFileBytes("file://$path")
.decodeToString()
.lineSequence()
.map { it.trim() }
@@ -1027,7 +1074,7 @@ object ApiClient {
recipientPublicKeyB64 = recipientPublicKey
)
http.put("${Config.apiBaseUrl}/dm/edit/$messageId") {
http.put("${ServerConfig.apiBaseUrl}/dm/edit/$messageId") {
contentType(ContentType.Application.Json)
setBody(body)
}
@@ -1035,20 +1082,20 @@ object ApiClient {
suspend fun fetchBackupBlob(): String? {
return try {
val response = http.get("${Config.apiBaseUrl}/crypto/backup") {
val response = http.get("${ServerConfig.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json)
}
val backupResponse = response.body<BackupBlobResponse>()
backupResponse.blob
} 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
}
}
suspend fun uploadBackupBlob(blobJson: String) {
val payload = BackupBlobRequest(blob = blobJson)
http.post("${Config.apiBaseUrl}/crypto/backup") {
http.post("${ServerConfig.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json)
setBody(payload)
}
@@ -1058,7 +1105,7 @@ object ApiClient {
suspend fun validateToken(): Boolean {
try {
http
.get("${Config.apiBaseUrl}/api/user/profile")
.get("${ServerConfig.apiBaseUrl}/api/user/profile")
return true // Token is valid if no exception thrown
} catch (e: ClientRequestException) {
if (e.response.status.value == 401 || e.response.status.value == 403) {
@@ -1073,27 +1120,27 @@ object ApiClient {
suspend fun listDevices(): List<DeviceSessionInfo> =
http
.get("${Config.apiBaseUrl}/devices") {
.get("${ServerConfig.apiBaseUrl}/devices") {
contentType(ContentType.Application.Json)
}
.body<DevicesListResponse>()
.devices
suspend fun revokeDeviceSession(sessionId: String) {
http.delete("${Config.apiBaseUrl}/devices/$sessionId") {
http.delete("${ServerConfig.apiBaseUrl}/devices/$sessionId") {
contentType(ContentType.Application.Json)
}
}
suspend fun revokeAllOtherDeviceSessions() {
http.post("${Config.apiBaseUrl}/devices/logout-all") {
http.post("${ServerConfig.apiBaseUrl}/devices/logout-all") {
contentType(ContentType.Application.Json)
}
}
suspend fun registerFcmToken(token: String): SimpleStatusResponse {
return http
.post("${Config.apiBaseUrl}/push/register") {
.post("${ServerConfig.apiBaseUrl}/push/register") {
contentType(ContentType.Application.Json)
setBody(FcmTokenRequest(token = token))
}
@@ -1103,13 +1150,13 @@ object ApiClient {
suspend fun unregisterFcmToken(token: String? = null): SimpleStatusResponse {
return if (token.isNullOrBlank()) {
http
.post("${Config.apiBaseUrl}/push/unregister") {
.post("${ServerConfig.apiBaseUrl}/push/unregister") {
contentType(ContentType.Application.Json)
}
.body()
} else {
http
.post("${Config.apiBaseUrl}/push/unregister") {
.post("${ServerConfig.apiBaseUrl}/push/unregister") {
contentType(ContentType.Application.Json)
setBody(FcmTokenRequest(token = token))
}
@@ -1122,7 +1169,7 @@ object ApiClient {
newPasswordDerived: String,
logoutAllExceptCurrent: Boolean
) {
http.post("${Config.apiBaseUrl}/change-password") {
http.post("${ServerConfig.apiBaseUrl}/change-password") {
contentType(ContentType.Application.Json)
setBody(
ChangePasswordApiRequest(
@@ -1140,14 +1187,14 @@ object ApiClient {
suspend fun deleteAccount(): SimpleStatusResponse {
try {
return http
.post("${Config.apiBaseUrl}/account/delete") {
.post("${ServerConfig.apiBaseUrl}/account/delete") {
contentType(ContentType.Application.Json)
}
.body()
} catch (e: ClientRequestException) {
if (e.response.status.value != 404) throw e
return http
.post("${Config.apiBaseUrl}/delete") {
.post("${ServerConfig.apiBaseUrl}/delete") {
contentType(ContentType.Application.Json)
}
.body()
@@ -1177,7 +1224,7 @@ object ApiClient {
}
suspend fun logout() {
runCatching { http.get("${Config.apiBaseUrl}/logout") }
runCatching { http.get("${ServerConfig.apiBaseUrl}/logout") }
runCatching { unregisterFcmTokenFromServer() }
clearLocalSession()
}
@@ -1186,7 +1233,7 @@ object ApiClient {
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
if (_suspensionState.value.isSuspended) return
http.post("${Config.apiBaseUrl}/send_message") {
http.post("${ServerConfig.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
setBody(SendMessageRequest(content = content, reply_to_id = replyToId))
}
@@ -1366,7 +1413,7 @@ object ApiClient {
throw IllegalStateException("Suspended")
}
return http
.post("${Config.apiBaseUrl}/livekit/token") {
.post("${ServerConfig.apiBaseUrl}/livekit/token") {
contentType(ContentType.Application.Json)
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
/**
* 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()
@@ -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
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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
/**
@@ -35,6 +42,7 @@ object UpdateSyncManager {
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
}
@OptIn(DelicateCoroutinesApi::class)
fun onUpdatesBatch(seq: Int) {
if (seq <= 0) return
val currentUserId = ApiClient.user?.id ?: return
@@ -1,8 +1,9 @@
package ru.fromchat.api
import ru.fromchat.core.Logger
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.Logger
suspend inline fun <Response> apiRequest(
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.Dispatchers
@@ -14,11 +14,11 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.visibleDisplayName
import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.Logger
import ru.fromchat.config.ServerConfig
private const val TAG = "CallStore"
@@ -105,7 +105,7 @@ object CallStore {
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
if (serverUrl == null || roomName == null || fromUserId == null) return
if (fromUserId == currentId) return
if (!Config.callsEnabled) {
if (!ServerConfig.callsEnabled) {
Logger.d(TAG, "call_signaling invite ignored (calls disabled in server config)")
return
}
@@ -122,7 +122,7 @@ object CallStore {
fun startOutgoingCall(peerUserId: Int) {
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)")
return
}
@@ -134,7 +134,7 @@ object CallStore {
val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
// LiveKit WS endpoint is exposed on the same host as the server config,
// using the configured calls port.
val signalUrl = Config.liveKitWsUrl()
val signalUrl = ServerConfig.liveKitWsUrl()
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl)
val label = peerLabel(peerUserId)
LiveKitConnectSession(
@@ -162,7 +162,7 @@ object CallStore {
fun acceptIncoming() {
val inc = _ui.value as? CallUiState.Incoming ?: return
if (!Config.callsEnabled) {
if (!ServerConfig.callsEnabled) {
Logger.d(TAG, "acceptIncoming ignored (calls disabled)")
return
}
@@ -175,7 +175,7 @@ object CallStore {
val display =
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
LiveKitConnectSession(
serverUrl = Config.liveKitWsUrl(),
serverUrl = ServerConfig.liveKitWsUrl(),
token = tok.token,
peerUserId = inc.fromUserId,
peerDisplayName = display,
@@ -1,4 +1,4 @@
package ru.fromchat.calls
package ru.fromchat.api.calls
data class LiveKitConnectSession(
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).
@@ -1,8 +1,12 @@
package ru.fromchat.crypto
package ru.fromchat.api.crypto
import com.pr0gramm3r101.utils.crypto.PasswordHash
import ru.fromchat.api.DmEnvelope
import ru.fromchat.crypto.dm.DmCrypto
import com.pr0gramm3r101.utils.files.PlatformFileSystem
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. */
/** 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.
*/
suspend fun decryptFileToPath(
file: ru.fromchat.api.DmFile,
file: DmFile,
envelope: DmEnvelope,
currentUserId: Int?,
outputPath: String,
@@ -71,8 +75,8 @@ suspend fun decryptFileToPath(
?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}")
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
val encryptedOnDisk = ru.fromchat.api.ApiClient.fetchEncryptedFileResumable(
Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
val encryptedOnDisk = ApiClient.fetchEncryptedFileResumable(
path = file.path,
resumeKey = downloadResumeKey,
onProgress = onDownloadProgress,
@@ -93,7 +97,7 @@ suspend fun decryptFileToPath(
decryptedSize
} finally {
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.settings.secureSettings
@@ -10,14 +10,15 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient
import ru.fromchat.api.PublicKeyResponse
import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
import ru.fromchat.crypto.backup.BackupCrypto
import ru.fromchat.crypto.backup.PrivateKeyBundle
import ru.fromchat.crypto.backup.decodeBlob
import ru.fromchat.crypto.backup.encodeBlob
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
import ru.fromchat.Logger
import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.backup.BackupCrypto
import ru.fromchat.api.crypto.backup.PrivateKeyBundle
import ru.fromchat.api.crypto.backup.decodeBlob
import ru.fromchat.api.crypto.backup.encodeBlob
import kotlin.concurrent.Volatile
/**
@@ -168,7 +169,7 @@ object IdentityKeyManager {
private suspend fun fetchBackupBlob(token: String): String? {
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>()
backupResponse.blob
} catch (e: Exception) {
@@ -179,7 +180,7 @@ object IdentityKeyManager {
private suspend fun uploadBackupBlob(blobJson: String, token: String) {
val payload = BackupBlobRequest(blob = blobJson)
ApiClient.http.post("${Config.apiBaseUrl}/crypto/backup") {
ApiClient.http.post("${ServerConfig.apiBaseUrl}/crypto/backup") {
contentType(ContentType.Application.Json)
setBody(payload)
}
@@ -197,24 +198,24 @@ object IdentityKeyManager {
private suspend fun uploadPublicKey(publicKey: ByteArray, token: String) {
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)
setBody(payload)
}
}
}
@kotlinx.serialization.Serializable
@Serializable
private data class BackupBlobResponse(
val blob: String?
)
@kotlinx.serialization.Serializable
@Serializable
private data class BackupBlobRequest(
val blob: String
)
@kotlinx.serialization.Serializable
@Serializable
private data class UploadPublicKeyRequest(
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 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.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].
@@ -21,11 +21,7 @@ internal suspend fun aesGcmDecryptMekFileToPath(
outputPath = outputPath,
)
} catch (e: Throwable) {
throw if (e is DmCiphertextCorruptedException) {
e
} else {
DmCiphertextCorruptedException(cause = e)
}
throw e as? DmCiphertextCorruptedException ?: DmCiphertextCorruptedException(cause = e)
}
}
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.dm
package ru.fromchat.api.crypto.dm
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).
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.transport
package ru.fromchat.api.crypto.transport
/**
* 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.
@@ -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"
@@ -1,4 +1,4 @@
package ru.fromchat.crypto.transport
package ru.fromchat.api.crypto.transport
/**
* 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.Dispatchers
import kotlinx.coroutines.SupervisorJob
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 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).
@@ -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.SocketTimeoutException
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import ru.fromchat.api.ApiClient
import ru.fromchat.api.db.InstanceRegistryStore
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.configKey
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfigData
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}$")
@@ -1,4 +1,6 @@
package ru.fromchat.core
package ru.fromchat.api.instance
import ru.fromchat.config.ServerConfigData
fun ServerConfigData.configKey(): String {
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 ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.db.InstanceRegistryStore
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.core.config.Config
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfigData
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.config.ServerConfig
import kotlin.time.TimeSource
sealed interface ServerProbeResult {
data class Supported(
@@ -64,7 +63,7 @@ suspend fun applyServerConfig(
callsOk: Boolean,
) {
val tentative = config.copy(callsEnabled = callsOk)
Config.updateServerConfig(tentative)
ServerConfig.updateServerConfig(tentative)
val userId = ApiClient.user?.id
InstanceRegistryStore.rebindServerInstance(tentative, instanceId)
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.Dispatchers
@@ -7,11 +7,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.db.InstanceRegistryStore
import ru.fromchat.core.Settings
import ru.fromchat.api.outbox.scheduleOutboxProcessing
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.config.Settings
import ru.fromchat.api.local.cache.CacheContext
sealed interface 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 ru.fromchat.core.Logger
/**
* Unified filter tag for attachment media pipeline (upload, download, disk/bitmap cache, tile decode).
@@ -36,4 +36,4 @@ object AttachmentMediaLog {
}
Logger.d(TAG, "[$subsystem] $message$suffix")
}
}
}
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat
package ru.fromchat.api.local
fun mimeTypeForFilename(filename: String): String {
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.webSocket
@@ -11,29 +11,36 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.core.Logger
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.core.config.Config
import ru.fromchat.core.instance.InstanceIdGuard
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UpdateSyncManager
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.coroutines.coroutineContext
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
@@ -41,7 +48,6 @@ object WebSocketManager {
private const val TAG = "WebSocketManager"
private const val MIN_RECONNECT_DELAY_MS = 1_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 FOREGROUND_DELAY_CHUNK_MS = 200L
@@ -91,10 +97,10 @@ object WebSocketManager {
private suspend fun delayWhileForeground(totalMs: Long) {
var remaining = totalMs
while (remaining > 0) {
coroutineContext.ensureActive()
currentCoroutineContext().ensureActive()
awaitForeground()
val chunk = minOf(FOREGROUND_DELAY_CHUNK_MS, remaining)
delay(chunk)
delay(chunk.milliseconds)
remaining -= chunk
}
}
@@ -104,7 +110,7 @@ object WebSocketManager {
if (session != null) return true
val startTime = Clock.System.now().toEpochMilliseconds()
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
delay(100)
delay(100.milliseconds)
}
logD("waitForConnection finished: session=${session != null}")
return session != null
@@ -144,7 +150,7 @@ object WebSocketManager {
}
try {
val wsUrl = Config.webSocketUrl
val wsUrl = ServerConfig.webSocketUrl
logD("Attempting to connect to: $wsUrl")
connecting = true
ConnectionStateStore.onConnecting()
@@ -194,7 +200,8 @@ object WebSocketManager {
val msg = when (messageType) {
"updates" -> {
runCatching {
val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), jsonTree)
val updatesData = json.decodeFromJsonElement(
WebSocketUpdatesData.serializer(), jsonTree)
UpdateSyncManager.onUpdatesBatch(updatesData.seq)
}.onFailure {
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
@@ -277,8 +284,8 @@ object WebSocketManager {
}
send(message)
withTimeout(timeoutMs) {
suspendCoroutine { continuation ->
withTimeout(timeoutMs.milliseconds) {
suspendCancellableCoroutine { continuation ->
handler = { response ->
if (response.type == message.type) {
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"
/** 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.db.MessageDatabaseProvider
import ru.fromchat.ui.chat.PublicChatPanelCache
import ru.fromchat.ui.dm.DmPanelCache
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.cache.CacheContext.activeInstanceId
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
/**
* 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.db.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.db.conversationIdForDm
import ru.fromchat.api.db.conversationIdForGroup
import ru.fromchat.api.db.dmOtherUserIdFromConversationId
import ru.fromchat.api.db.groupIdFromConversationId
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.messages.dmOtherUserIdFromConversationId
import ru.fromchat.api.local.messages.groupIdFromConversationId
import ru.fromchat.api.schema.messages.Message
/**
* 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 kotlinx.coroutines.CancellationException
@@ -7,13 +7,17 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.AttachmentDownloadProgress
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.crypto.dm.decryptFailureMessage
import ru.fromchat.core.cache.copyOutboundFileToPath
import ru.fromchat.crypto.decryptFileToPath
import ru.fromchat.api.crypto.decryptFileToPath
import ru.fromchat.api.crypto.dm.decryptFailureMessage
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.download.AttachmentDownloadProgress
import ru.fromchat.api.local.download.AttachmentDownloadScheduler
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).
@@ -1,4 +1,4 @@
package ru.fromchat.ui.chat
package ru.fromchat.api.local.cache
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import kotlinx.coroutines.CancellationException
@@ -7,11 +7,18 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.AttachmentDownloadProgress
import ru.fromchat.crypto.decryptFileToPath
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.download.AttachmentDownloadProgress
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile
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.
@@ -112,7 +119,7 @@ object DecryptedImageCache {
val cidKey = storageKey(messageId, fileIndex, cid)
val sourceUri = readDisk(cidKey) ?: return
val bytes = runCatching {
ru.fromchat.core.cache.readOutboundFileBytes(sourceUri)
readOutboundFileBytes(sourceUri)
}.getOrNull() ?: return
if (bytes.isEmpty()) return
withContext(Dispatchers.Default) {
@@ -286,7 +293,7 @@ object DecryptedImageCache {
}
val t0 = AttachmentMediaLog.nowMs()
val bytes = runCatching {
ru.fromchat.core.cache.readOutboundFileBytes(localFileUri)
readOutboundFileBytes(localFileUri)
}.getOrNull()
if (bytes == null) {
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).
@@ -7,4 +7,4 @@ internal expect class FileWriteSink(path: String, append: Boolean) : AutoCloseab
fun write(buffer: ByteArray, offset: Int, length: Int)
fun flush()
override fun close()
}
}
@@ -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]. */
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.
@@ -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.). */
class OutboundFileUnavailableException(

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