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