mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Refactor the whole project structure
This commit is contained in:
Submodule .cursor/skills/material-3-skill updated: 313f584217...bc4e480586
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -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()
|
||||
}
|
||||
+6
-7
@@ -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)))
|
||||
+5
-1
@@ -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
|
||||
+3
-3
@@ -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 {
|
||||
+4
-3
@@ -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()
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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(
|
||||
+3
-2
@@ -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,
|
||||
+3
-5
@@ -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,
|
||||
@@ -53,7 +55,3 @@ class AttachmentFileCopyWorker(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun enqueuePlatformCopy(storageKey: String) {
|
||||
AttachmentFileCopyWorker.enqueue(storageKey)
|
||||
}
|
||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.core.files
|
||||
package ru.fromchat.api.local.cache
|
||||
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.core.cache
|
||||
package ru.fromchat.api.local.cache
|
||||
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
import java.io.File
|
||||
+3
-4
@@ -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"
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
package ru.fromchat.api.local.cache
|
||||
|
||||
internal actual fun enqueuePlatformCopy(storageKey: String) {
|
||||
AttachmentFileCopyWorker.enqueue(storageKey)
|
||||
}
|
||||
+3
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.api.db
|
||||
package ru.fromchat.api.local.db
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
+4
-4
@@ -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) {
|
||||
+2
-2
@@ -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) {
|
||||
+5
-3
@@ -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
-1
@@ -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
|
||||
+66
-23
@@ -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
|
||||
|
||||
return scaleBitmapToFitWithin(
|
||||
applyExifOrientation(
|
||||
BitmapFactory.decodeFile(
|
||||
path,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = calculateInSampleSize(
|
||||
orientedW,
|
||||
orientedH,
|
||||
reqWidthPx,
|
||||
reqHeightPx
|
||||
)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
val decoded = BitmapFactory.decodeFile(path, options) ?: return null
|
||||
val oriented = applyExifOrientation(decoded, orientation)
|
||||
return scaleBitmapToFitWithin(oriented, reqWidthPx, reqHeightPx)
|
||||
) ?: 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
|
||||
|
||||
return scaleBitmapToFitWithin(
|
||||
BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = calculateInSampleSize(
|
||||
bounds.outWidth,
|
||||
bounds.outHeight,
|
||||
reqWidthPx,
|
||||
reqHeightPx
|
||||
)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
|
||||
return scaleBitmapToFitWithin(decoded, reqWidthPx, reqHeightPx)
|
||||
) ?: 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)
|
||||
}
|
||||
+17
-6
@@ -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
|
||||
+5
-3
@@ -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) {
|
||||
onComplete(
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
UtilsLibrary.context.contentResolver.openOutputStream(destination)?.use { out ->
|
||||
out.write(bytes)
|
||||
} != null
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
onComplete(ok)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.api.outbox
|
||||
package ru.fromchat.api.local.send
|
||||
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
+4
-4
@@ -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,
|
||||
+6
-5
@@ -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
-2
@@ -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?) {
|
||||
+5
-4
@@ -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"
|
||||
+6
-5
@@ -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
|
||||
)
|
||||
}
|
||||
+6
-4
@@ -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(
|
||||
+36
-24
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.ui.chat
|
||||
package ru.fromchat.ui.chat.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.ui.chat
|
||||
package ru.fromchat.ui.chat.utils
|
||||
|
||||
import android.widget.Toast
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
+118
@@ -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("*/*"))
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -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()
|
||||
}
|
||||
+9
-9
@@ -1,40 +1,40 @@
|
||||
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 {
|
||||
-28
@@ -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
-2
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat
|
||||
package ru.fromchat.ui.profile
|
||||
|
||||
import android.widget.Toast
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
@@ -6,4 +6,3 @@ import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
actual fun showProfileLoadErrorMessage(message: String) {
|
||||
Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
+6
-2
@@ -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 phone’s 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">Couldn’t 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
-2
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.core
|
||||
package ru.fromchat
|
||||
|
||||
expect object Logger {
|
||||
fun d(tag: String, message: String, throwable: Throwable? = null)
|
||||
@@ -6,4 +6,3 @@ expect object Logger {
|
||||
fun w(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun 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
-2
@@ -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
-1
@@ -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
|
||||
)
|
||||
+11
-11
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.calls
|
||||
package ru.fromchat.api.calls
|
||||
|
||||
data class LiveKitConnectSession(
|
||||
val serverUrl: String,
|
||||
+1
-1
@@ -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).
|
||||
+11
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-14
@@ -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
-1
@@ -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
|
||||
+3
-7
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.crypto.dm
|
||||
package ru.fromchat.api.crypto.dm
|
||||
|
||||
import com.pr0gramm3r101.utils.crypto.Base64
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.crypto.transport
|
||||
package ru.fromchat.api.crypto.transport
|
||||
|
||||
/**
|
||||
* Result of client-side transport encryption for DMs.
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.crypto.transport
|
||||
package ru.fromchat.api.crypto.transport
|
||||
|
||||
/**
|
||||
* Streaming transport file blob (AES-256-GCM, chunked).
|
||||
+5
-4
@@ -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).
|
||||
+5
-6
@@ -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}$")
|
||||
+3
-1
@@ -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"
|
||||
+8
-9
@@ -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)
|
||||
+6
-6
@@ -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
|
||||
+2
-2
@@ -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).
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.ui.chat
|
||||
package ru.fromchat.api.local
|
||||
|
||||
fun mimeTypeForFilename(filename: String): String {
|
||||
val ext = filename.substringAfterLast('.').lowercase()
|
||||
+23
-16
@@ -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))
|
||||
+2
-2
@@ -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). */
|
||||
+6
-5
@@ -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.
|
||||
+7
-7
@@ -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.
|
||||
+12
-8
@@ -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).
|
||||
+15
-8
@@ -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
-1
@@ -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).
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
Reference in New Issue
Block a user