Compare commits
17 Commits
4640b0128f
...
9afbbae5c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 9afbbae5c9 | |||
| 4440016d5f | |||
| 0353f845a5 | |||
| 004b54ec7c | |||
| 5ecb2a8db4 | |||
| f34e520d03 | |||
| 1ba173be54 | |||
| d41674ff78 | |||
| bd6e033ed3 | |||
| 72a2cf1b70 | |||
| 2cf64e80eb | |||
| 2602084764 | |||
| 420ea6e560 | |||
| 53946e2e6e | |||
| 4d4130fefd | |||
| 09df7586e7 | |||
| 13b61cf720 |
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.2.3"
|
||||
val rosettaVersionCode = 25 // Increment on each release
|
||||
val rosettaVersionName = "1.2.4"
|
||||
val rosettaVersionCode = 26 // Increment on each release
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
|
||||
@@ -19,14 +19,48 @@ object ReleaseNotes {
|
||||
|
||||
Что обновлено после версии 1.2.3
|
||||
|
||||
Группы и медиа
|
||||
- Исправлено отображение групповых баблов и стеков сообщений
|
||||
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются
|
||||
- Исправлена обрезка имени отправителя в медиа-баблах
|
||||
- Исправлено растяжение фото в forwarded/media-пузырях
|
||||
Чат-лист и Requests
|
||||
- Полностью переработано поведение блока Requests: pull-жест, раскрытие и скрытие как у архива в Telegram
|
||||
- Доработана вытягивающаяся анимация: requests сразу появляются первым элементом при pull вниз
|
||||
- Убраны рывки и прыжки списка чатов при анимациях и при пустом списке запросов
|
||||
|
||||
Интерфейс
|
||||
- Убрана лишняя рамка вокруг аватарки в боковом меню
|
||||
Чаты и группы
|
||||
- Исправлены групповые баблы и аватарки в стеках сообщений, устранены кривые состояния в медиа-блоках
|
||||
- Исправлена обрезка имени отправителя в групповых медиа-сообщениях
|
||||
- Плашки даты в диалоге приведены к Telegram-стилю, добавлена плавающая верхняя дата при скролле
|
||||
- Сообщение «you joined the group» теперь белого цвета в тёмной теме и на обоях
|
||||
|
||||
Медиа и локальные данные
|
||||
- Исправлена отправка нескольких фото: добавлен корректный optimistic UI и стабильное отображение до/после перезахода
|
||||
- Экран редактирования фото после камеры унифицирован с редактором фото из галереи
|
||||
- Удалённые сообщения теперь корректно удаляются локально и не возвращаются после открытия диалога
|
||||
|
||||
Обои и темы
|
||||
- Разделены наборы обоев для светлой и тёмной темы
|
||||
- Исправлено поведение обоев на разных разрешениях: убраны повторения/растяжения, фон отображается стабильнее
|
||||
|
||||
Навигация и UI
|
||||
- Back-свайп теперь везде скрывает клавиатуру (как на экране поиска)
|
||||
- На экране группы выровнены размеры иконок Encryption Key и Add Members
|
||||
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
|
||||
- Приведён к нормальному размер индикатор ошибки в чат-листе
|
||||
|
||||
Медиапикер и камера
|
||||
- Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры
|
||||
- Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения
|
||||
- Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss
|
||||
|
||||
Файлы и загрузки
|
||||
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
|
||||
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
|
||||
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
|
||||
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
|
||||
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
|
||||
- Обновлён экран активных загрузок: добавлен статус Paused
|
||||
|
||||
Групповые сообщения
|
||||
- Добавлен truncate для длинных имён отправителей в групповых пузырьках
|
||||
- Убраны переносы в имени отправителя в шапке группового сообщения
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import kotlinx.coroutines.*
|
||||
@@ -20,6 +19,7 @@ data class FileDownloadState(
|
||||
enum class FileDownloadStatus {
|
||||
QUEUED,
|
||||
DOWNLOADING,
|
||||
PAUSED,
|
||||
DECRYPTING,
|
||||
DONE,
|
||||
ERROR
|
||||
@@ -35,6 +35,28 @@ object FileDownloadManager {
|
||||
|
||||
/** Текущие Job'ы — чтобы не запускать повторно */
|
||||
private val jobs = mutableMapOf<String, Job>()
|
||||
/** Последние параметры скачивания — нужны для resume */
|
||||
private val requests = mutableMapOf<String, DownloadRequest>()
|
||||
/** Флаг, что cancel произошёл именно как user pause */
|
||||
private val pauseRequested = mutableSetOf<String>()
|
||||
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
|
||||
private val resumeAfterPause = mutableSetOf<String>()
|
||||
|
||||
private data class DownloadRequest(
|
||||
val attachmentId: String,
|
||||
val downloadTag: String,
|
||||
val chachaKey: String,
|
||||
val privateKey: String,
|
||||
val accountPublicKey: String,
|
||||
val fileName: String,
|
||||
val savedFile: File
|
||||
)
|
||||
|
||||
private fun encryptedPartFile(request: DownloadRequest): File {
|
||||
val parent = request.savedFile.parentFile ?: request.savedFile.absoluteFile.parentFile
|
||||
val safeId = request.attachmentId.take(32).replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
return File(parent, ".dl_${safeId}.part")
|
||||
}
|
||||
|
||||
// ─── helpers ───
|
||||
|
||||
@@ -67,9 +89,16 @@ object FileDownloadManager {
|
||||
*/
|
||||
fun isDownloading(attachmentId: String): Boolean {
|
||||
val state = _downloads.value[attachmentId] ?: return false
|
||||
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
|
||||
return state.status == FileDownloadStatus.QUEUED ||
|
||||
state.status == FileDownloadStatus.DOWNLOADING ||
|
||||
state.status == FileDownloadStatus.DECRYPTING
|
||||
}
|
||||
|
||||
fun isPaused(attachmentId: String): Boolean =
|
||||
_downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED
|
||||
|
||||
fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId]
|
||||
|
||||
/**
|
||||
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
||||
*/
|
||||
@@ -81,7 +110,6 @@ object FileDownloadManager {
|
||||
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||
*/
|
||||
fun download(
|
||||
context: Context,
|
||||
attachmentId: String,
|
||||
downloadTag: String,
|
||||
chachaKey: String,
|
||||
@@ -90,132 +118,234 @@ object FileDownloadManager {
|
||||
fileName: String,
|
||||
savedFile: File
|
||||
) {
|
||||
// Уже в процессе?
|
||||
if (jobs[attachmentId]?.isActive == true) return
|
||||
val normalizedAccount = accountPublicKey.trim()
|
||||
val savedPath = savedFile.absolutePath
|
||||
val request = DownloadRequest(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
accountPublicKey = accountPublicKey.trim(),
|
||||
fileName = fileName,
|
||||
savedFile = savedFile
|
||||
)
|
||||
requests[attachmentId] = request
|
||||
startDownload(request)
|
||||
}
|
||||
|
||||
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
|
||||
fun pause(attachmentId: String) {
|
||||
val current = _downloads.value[attachmentId] ?: return
|
||||
if (
|
||||
current.status == FileDownloadStatus.DONE ||
|
||||
current.status == FileDownloadStatus.ERROR
|
||||
) return
|
||||
|
||||
jobs[attachmentId] = scope.launch {
|
||||
try {
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
pauseRequested.add(attachmentId)
|
||||
val pausedProgress = current.progress.coerceIn(0f, 0.98f)
|
||||
update(
|
||||
id = attachmentId,
|
||||
fileName = current.fileName,
|
||||
status = FileDownloadStatus.PAUSED,
|
||||
progress = pausedProgress,
|
||||
accountPublicKey = current.accountPublicKey,
|
||||
savedPath = current.savedPath
|
||||
)
|
||||
|
||||
// Запускаем polling прогресса из TransportManager
|
||||
val progressJob = launch {
|
||||
TransportManager.downloading.collect { list ->
|
||||
val entry = list.find { it.id == attachmentId }
|
||||
if (entry != null) {
|
||||
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
||||
val p = (entry.progress / 100f) * 0.8f
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
p,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TransportManager.cancelDownload(attachmentId)
|
||||
jobs[attachmentId]?.cancel()
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
downloadGroupFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile,
|
||||
accountPublicKey = normalizedAccount,
|
||||
savedPath = savedPath
|
||||
)
|
||||
} else {
|
||||
downloadDirectFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile,
|
||||
accountPublicKey = normalizedAccount,
|
||||
savedPath = savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
progressJob.cancel()
|
||||
|
||||
if (success) {
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DONE,
|
||||
1f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} else {
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} finally {
|
||||
jobs.remove(attachmentId)
|
||||
// Автоочистка через 5 секунд после завершения
|
||||
scope.launch {
|
||||
delay(5000)
|
||||
_downloads.update { it - attachmentId }
|
||||
}
|
||||
}
|
||||
fun resume(attachmentId: String) {
|
||||
val request = requests[attachmentId] ?: return
|
||||
if (jobs[attachmentId]?.isActive == true) {
|
||||
resumeAfterPause.add(attachmentId)
|
||||
return
|
||||
}
|
||||
pauseRequested.remove(attachmentId)
|
||||
startDownload(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменяет скачивание
|
||||
*/
|
||||
fun cancel(attachmentId: String) {
|
||||
requests[attachmentId]?.let { req ->
|
||||
encryptedPartFile(req).delete()
|
||||
}
|
||||
pauseRequested.remove(attachmentId)
|
||||
resumeAfterPause.remove(attachmentId)
|
||||
requests.remove(attachmentId)
|
||||
TransportManager.cancelDownload(attachmentId)
|
||||
jobs[attachmentId]?.cancel()
|
||||
jobs.remove(attachmentId)
|
||||
_downloads.update { it - attachmentId }
|
||||
}
|
||||
|
||||
private fun startDownload(request: DownloadRequest) {
|
||||
val attachmentId = request.attachmentId
|
||||
if (jobs[attachmentId]?.isActive == true) return
|
||||
|
||||
pauseRequested.remove(attachmentId)
|
||||
|
||||
val savedPath = request.savedFile.absolutePath
|
||||
val encryptedPart = encryptedPartFile(request)
|
||||
val resumeBase =
|
||||
(_downloads.value[attachmentId]
|
||||
?.takeIf { it.status == FileDownloadStatus.PAUSED }
|
||||
?.progress
|
||||
?: 0f).coerceIn(0f, 0.8f)
|
||||
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.QUEUED,
|
||||
resumeBase,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
jobs[attachmentId] = scope.launch {
|
||||
var progressJob: Job? = null
|
||||
try {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
resumeBase,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
// Запускаем polling прогресса из TransportManager.
|
||||
// Держим прогресс монотонным, чтобы он не дёргался вниз.
|
||||
progressJob = launch {
|
||||
TransportManager.downloading.collect { list ->
|
||||
val entry = list.find { it.id == attachmentId } ?: return@collect
|
||||
val rawCdn = (entry.progress / 100f) * 0.8f
|
||||
val current = _downloads.value[attachmentId]?.progress ?: 0f
|
||||
val stable = maxOf(current, rawCdn).coerceIn(0f, 0.8f)
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
stable,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (isGroupStoredKey(request.chachaKey)) {
|
||||
downloadGroupFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = request.downloadTag,
|
||||
chachaKey = request.chachaKey,
|
||||
privateKey = request.privateKey,
|
||||
fileName = request.fileName,
|
||||
savedFile = request.savedFile,
|
||||
encryptedPartFile = encryptedPart,
|
||||
accountPublicKey = request.accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
} else {
|
||||
downloadDirectFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = request.downloadTag,
|
||||
chachaKey = request.chachaKey,
|
||||
privateKey = request.privateKey,
|
||||
fileName = request.fileName,
|
||||
savedFile = request.savedFile,
|
||||
encryptedPartFile = encryptedPart,
|
||||
accountPublicKey = request.accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DONE,
|
||||
1f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
encryptedPart.delete()
|
||||
requests.remove(attachmentId)
|
||||
} else {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
if (pauseRequested.remove(attachmentId)) {
|
||||
val current = _downloads.value[attachmentId]
|
||||
val pausedProgress = (current?.progress ?: resumeBase).coerceIn(0f, 0.98f)
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.PAUSED,
|
||||
pausedProgress,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} finally {
|
||||
progressJob?.cancel()
|
||||
jobs.remove(attachmentId)
|
||||
|
||||
if (resumeAfterPause.remove(attachmentId)) {
|
||||
scope.launch { startDownload(request) }
|
||||
}
|
||||
|
||||
// Автоочистка только терминальных состояний.
|
||||
val terminalStatus = _downloads.value[attachmentId]?.status
|
||||
if (
|
||||
terminalStatus == FileDownloadStatus.DONE ||
|
||||
terminalStatus == FileDownloadStatus.ERROR
|
||||
) {
|
||||
scope.launch {
|
||||
delay(5000)
|
||||
val current = _downloads.value[attachmentId]
|
||||
if (
|
||||
current?.status == FileDownloadStatus.DONE ||
|
||||
current?.status == FileDownloadStatus.ERROR
|
||||
) {
|
||||
_downloads.update { it - attachmentId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internal download logic (moved from FileAttachment) ───
|
||||
|
||||
private suspend fun downloadGroupFile(
|
||||
@@ -225,10 +355,21 @@ object FileDownloadManager {
|
||||
privateKey: String,
|
||||
fileName: String,
|
||||
savedFile: File,
|
||||
encryptedPartFile: File,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
): Boolean {
|
||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
|
||||
val encryptedFile =
|
||||
TransportManager.downloadFileRawResumable(
|
||||
id = attachmentId,
|
||||
tag = downloadTag,
|
||||
targetFile = encryptedPartFile,
|
||||
resumeFromBytes = resumeBytes
|
||||
)
|
||||
val encryptedContent = withContext(Dispatchers.IO) {
|
||||
encryptedFile.readText(Charsets.UTF_8)
|
||||
}
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
@@ -283,11 +424,18 @@ object FileDownloadManager {
|
||||
privateKey: String,
|
||||
fileName: String,
|
||||
savedFile: File,
|
||||
encryptedPartFile: File,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
): Boolean {
|
||||
// Streaming: скачиваем во temp file
|
||||
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
||||
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
|
||||
val tempFile =
|
||||
TransportManager.downloadFileRawResumable(
|
||||
id = attachmentId,
|
||||
tag = downloadTag,
|
||||
targetFile = encryptedPartFile,
|
||||
resumeFromBytes = resumeBytes
|
||||
)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
@@ -316,7 +464,7 @@ object FileDownloadManager {
|
||||
savedFile
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
encryptedPartFile.delete()
|
||||
}
|
||||
}
|
||||
update(
|
||||
@@ -339,12 +487,19 @@ object FileDownloadManager {
|
||||
savedPath: String
|
||||
) {
|
||||
_downloads.update { map ->
|
||||
val previous = map[id]
|
||||
val normalizedProgress =
|
||||
when (status) {
|
||||
FileDownloadStatus.DONE -> 1f
|
||||
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
|
||||
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
|
||||
}
|
||||
map + (
|
||||
id to FileDownloadState(
|
||||
attachmentId = id,
|
||||
fileName = fileName,
|
||||
status = status,
|
||||
progress = progress,
|
||||
progress = normalizedProgress,
|
||||
accountPublicKey = accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Состояние загрузки/скачивания файла
|
||||
@@ -41,6 +47,7 @@ object TransportManager {
|
||||
|
||||
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
@@ -93,6 +100,8 @@ object TransportManager {
|
||||
repeat(MAX_RETRIES) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
@@ -110,6 +119,54 @@ object TransportManager {
|
||||
val packet = PacketRequestTransport()
|
||||
ProtocolManager.sendPacket(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительно отменяет активный HTTP call для скачивания attachment.
|
||||
* Нужен для pause/resume в file bubble.
|
||||
*/
|
||||
fun cancelDownload(id: String) {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
|
||||
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val call = client.newCall(request)
|
||||
activeDownloadCalls[id] = call
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
activeDownloadCalls.remove(id, call)
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activeDownloadCalls.remove(id, call)
|
||||
if (call.isCanceled()) {
|
||||
cont.cancel(CancellationException("Download cancelled"))
|
||||
} else {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
activeDownloadCalls.remove(id, call)
|
||||
if (cont.isCancelled) {
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parseContentRangeTotal(value: String?): Long? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
// Example: "bytes 100-999/12345"
|
||||
val totalPart = value.substringAfter('/').trim()
|
||||
if (totalPart.isEmpty() || totalPart == "*") return null
|
||||
return totalPart.toLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить файл на транспортный сервер с отслеживанием прогресса
|
||||
@@ -226,17 +283,7 @@ object TransportManager {
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = suspendCoroutine<Response> { cont ->
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
val response = awaitDownloadResponse(id, request)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Download failed: ${response.code}")
|
||||
@@ -310,6 +357,7 @@ object TransportManager {
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
// Удаляем из списка скачиваний
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
@@ -338,84 +386,129 @@ object TransportManager {
|
||||
* @return Временный файл с зашифрованным содержимым
|
||||
*/
|
||||
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer()
|
||||
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
||||
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
|
||||
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
|
||||
try {
|
||||
downloadFileRawResumable(
|
||||
id = id,
|
||||
tag = tag,
|
||||
targetFile = tempFile,
|
||||
resumeFromBytes = 0L
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
tempFile.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||
/**
|
||||
* Resumable download with HTTP Range support.
|
||||
* If server supports range (206), continues from `targetFile.length()`.
|
||||
* If not, safely restarts from zero and rewrites target file.
|
||||
*/
|
||||
suspend fun downloadFileRawResumable(
|
||||
id: String,
|
||||
tag: String,
|
||||
targetFile: File,
|
||||
resumeFromBytes: Long = 0L
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer()
|
||||
ProtocolManager.addLog(
|
||||
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
|
||||
)
|
||||
|
||||
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
|
||||
|
||||
try {
|
||||
withRetry {
|
||||
val request = Request.Builder()
|
||||
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
|
||||
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
|
||||
.coerceAtMost(existingBytes)
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url("$server/d/$tag")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = suspendCoroutine<Response> { cont ->
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
if (startOffset > 0L) {
|
||||
requestBuilder.addHeader("Range", "bytes=$startOffset-")
|
||||
}
|
||||
|
||||
val response = awaitDownloadResponse(id, requestBuilder.build())
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Download failed: ${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IOException("Empty response body")
|
||||
val contentLength = body.contentLength()
|
||||
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
|
||||
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
|
||||
val rangeAccepted = response.code == 206
|
||||
val writeFromOffset = if (rangeAccepted) startOffset else 0L
|
||||
val incomingLength = body.contentLength().coerceAtLeast(0L)
|
||||
val totalFromHeader = parseContentRangeTotal(response.header("Content-Range"))
|
||||
val totalBytes = when {
|
||||
totalFromHeader != null && totalFromHeader > 0L -> totalFromHeader
|
||||
incomingLength > 0L -> writeFromOffset + incomingLength
|
||||
else -> -1L
|
||||
}
|
||||
|
||||
try {
|
||||
var totalRead = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
if (writeFromOffset == 0L && targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
targetFile.parentFile?.mkdirs()
|
||||
|
||||
body.byteStream().use { inputStream ->
|
||||
tempFile.outputStream().use { outputStream ->
|
||||
while (true) {
|
||||
val bytesRead = inputStream.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = progress) else it
|
||||
}
|
||||
val append = writeFromOffset > 0L
|
||||
var totalRead = writeFromOffset
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
|
||||
body.byteStream().use { inputStream ->
|
||||
java.io.FileOutputStream(targetFile, append).use { outputStream ->
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val bytesRead = try {
|
||||
inputStream.read(buffer)
|
||||
} catch (e: IOException) {
|
||||
if (!coroutineContext.isActive) {
|
||||
throw CancellationException("Download cancelled", e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if (bytesRead == -1) break
|
||||
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
|
||||
if (totalBytes > 0L) {
|
||||
val progress =
|
||||
((totalRead * 100L) / totalBytes).toInt().coerceIn(0, 99)
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = progress) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength > 0 && totalRead != contentLength) {
|
||||
tempFile.delete()
|
||||
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
|
||||
}
|
||||
if (totalRead == 0L) {
|
||||
tempFile.delete()
|
||||
throw IOException("Empty download: 0 bytes received")
|
||||
}
|
||||
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
|
||||
tempFile
|
||||
} catch (e: Exception) {
|
||||
tempFile.delete()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (totalBytes > 0L && totalRead < totalBytes) {
|
||||
throw IOException(
|
||||
"Incomplete download: expected=$totalBytes, got=$totalRead"
|
||||
)
|
||||
}
|
||||
if (totalRead == 0L) {
|
||||
throw IOException("Empty download: 0 bytes received")
|
||||
}
|
||||
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolManager.addLog(
|
||||
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
|
||||
)
|
||||
targetFile
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
"❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,7 @@ package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Shader
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -80,7 +74,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -101,7 +94,6 @@ import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||
import com.rosetta.messenger.ui.chats.components.*
|
||||
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
|
||||
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
|
||||
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
|
||||
import com.rosetta.messenger.ui.chats.input.*
|
||||
@@ -128,6 +120,7 @@ import kotlinx.coroutines.withContext
|
||||
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
||||
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
|
||||
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
||||
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||
|
||||
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
|
||||
val firstCalendar =
|
||||
@@ -241,6 +234,8 @@ fun ChatDetailScreen(
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor
|
||||
val dateHeaderBackgroundColor =
|
||||
if (isDarkTheme || hasChatWallpaper) Color(0x80212121) else Color(0xCCE8E8ED)
|
||||
val headerIconColor = Color.White
|
||||
|
||||
// 🔥 Keyboard & Emoji Coordinator
|
||||
@@ -353,13 +348,17 @@ fun ChatDetailScreen(
|
||||
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
||||
// 🎨 Управление статус баром.
|
||||
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
|
||||
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
if (showImageViewer) {
|
||||
SystemBarsStyleUtils.applyFullscreenDark(window, view)
|
||||
} else {
|
||||
if (window != null && view != null) {
|
||||
val isOverlayControllingSystemBars = showMediaPicker
|
||||
|
||||
if (!isOverlayControllingSystemBars && window != null && view != null) {
|
||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
ic.isAppearanceLightStatusBars = false
|
||||
@@ -398,6 +397,7 @@ fun ChatDetailScreen(
|
||||
var pendingCameraPhotoUri by remember {
|
||||
mutableStateOf<Uri?>(null)
|
||||
} // Фото для редактирования
|
||||
var pendingCameraPhotoCaption by remember { mutableStateOf("") }
|
||||
|
||||
// 📷 Показать встроенную камеру (без системного превью)
|
||||
var showInAppCamera by remember { mutableStateOf(false) }
|
||||
@@ -636,6 +636,15 @@ fun ChatDetailScreen(
|
||||
// If typing, the user is obviously online — never show "offline" while typing
|
||||
val isOnline = rawIsOnline || isTyping
|
||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||
val showMessageSkeleton by
|
||||
produceState(initialValue = false, key1 = isLoading) {
|
||||
if (!isLoading) {
|
||||
value = false
|
||||
return@produceState
|
||||
}
|
||||
delay(MESSAGE_SKELETON_VISIBILITY_DELAY_MS)
|
||||
value = isLoading
|
||||
}
|
||||
|
||||
// <20>🔥 Reply/Forward state
|
||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||
@@ -1163,6 +1172,37 @@ fun ChatDetailScreen(
|
||||
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
|
||||
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage }
|
||||
}
|
||||
val floatingDateText by remember(messagesWithDates, listState) {
|
||||
derivedStateOf {
|
||||
if (messagesWithDates.isEmpty()) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
val topVisibleItem =
|
||||
visibleItems.minByOrNull { itemInfo ->
|
||||
kotlin.math.abs(itemInfo.offset - layoutInfo.viewportStartOffset)
|
||||
} ?: return@derivedStateOf null
|
||||
val messageIndex = topVisibleItem.index
|
||||
if (messageIndex !in messagesWithDates.indices) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
getDateText(messagesWithDates[messageIndex].first.timestamp.time)
|
||||
}
|
||||
}
|
||||
val showFloatingDateHeader by
|
||||
remember(messagesWithDates, floatingDateText, isAtBottom, listState) {
|
||||
derivedStateOf {
|
||||
messagesWithDates.isNotEmpty() &&
|
||||
floatingDateText != null &&
|
||||
!isAtBottom &&
|
||||
(listState.isScrollInProgress ||
|
||||
listState.firstVisibleItemIndex > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
||||
// 🔥 Скроллим только если изменился ID самого нового сообщения
|
||||
@@ -2335,10 +2375,9 @@ fun ChatDetailScreen(
|
||||
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||
// when content paddings (bottom bar/IME) change.
|
||||
if (chatWallpaperResId != null) {
|
||||
TiledChatWallpaper(
|
||||
ChatWallpaperBackground(
|
||||
wallpaperResId = chatWallpaperResId,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
tileScale = 0.9f
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
@@ -2365,7 +2404,7 @@ fun ChatDetailScreen(
|
||||
when {
|
||||
// 🔥 СКЕЛЕТОН - показываем пока загружаются
|
||||
// сообщения
|
||||
isLoading -> {
|
||||
showMessageSkeleton -> {
|
||||
MessageSkeletonList(
|
||||
isDarkTheme = isDarkTheme,
|
||||
isGroupChat = isGroupChat,
|
||||
@@ -2373,6 +2412,9 @@ fun ChatDetailScreen(
|
||||
Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
isLoading && messages.isEmpty() -> {
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
// Пустое состояние (нет сообщений)
|
||||
messages.isEmpty() -> {
|
||||
Column(
|
||||
@@ -2563,12 +2605,41 @@ fun ChatDetailScreen(
|
||||
isMessageBoundary(message, prevMessage)
|
||||
val isGroupStart =
|
||||
isMessageBoundary(message, nextMessage)
|
||||
val runHeadIndex =
|
||||
messageRunNewestIndex.getOrNull(
|
||||
index
|
||||
) ?: index
|
||||
val runTailIndex =
|
||||
messageRunOldestIndexByHead
|
||||
.getOrNull(
|
||||
runHeadIndex
|
||||
)
|
||||
?: runHeadIndex
|
||||
val isHeadPhase =
|
||||
incomingRunAvatarUiState
|
||||
.showOnRunHeads
|
||||
.contains(
|
||||
runHeadIndex
|
||||
)
|
||||
val isTailPhase =
|
||||
incomingRunAvatarUiState
|
||||
.showOnRunTails
|
||||
.contains(
|
||||
runHeadIndex
|
||||
)
|
||||
val showIncomingGroupAvatar =
|
||||
isGroupChat &&
|
||||
!message.isOutgoing &&
|
||||
senderPublicKeyForMessage
|
||||
.isNotBlank() &&
|
||||
isGroupStart
|
||||
((index ==
|
||||
runHeadIndex &&
|
||||
isHeadPhase &&
|
||||
showTail) ||
|
||||
(index ==
|
||||
runTailIndex &&
|
||||
isTailPhase &&
|
||||
isGroupStart))
|
||||
|
||||
Column {
|
||||
if (showDate
|
||||
@@ -2579,8 +2650,10 @@ fun ChatDetailScreen(
|
||||
message.timestamp
|
||||
.time
|
||||
),
|
||||
secondaryTextColor =
|
||||
dateHeaderTextColor
|
||||
textColor =
|
||||
dateHeaderTextColor,
|
||||
backgroundColor =
|
||||
dateHeaderBackgroundColor
|
||||
)
|
||||
}
|
||||
val selectionKey =
|
||||
@@ -2951,6 +3024,42 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible =
|
||||
showFloatingDateHeader &&
|
||||
!isLoading &&
|
||||
!isSelectionMode,
|
||||
enter =
|
||||
fadeIn(animationSpec = tween(120)) +
|
||||
slideInVertically(
|
||||
animationSpec =
|
||||
tween(120)
|
||||
) { height ->
|
||||
-height / 2
|
||||
},
|
||||
exit =
|
||||
fadeOut(animationSpec = tween(100)) +
|
||||
slideOutVertically(
|
||||
animationSpec =
|
||||
tween(100)
|
||||
) { height ->
|
||||
-height / 2
|
||||
},
|
||||
modifier =
|
||||
Modifier.align(Alignment.TopCenter)
|
||||
.padding(top = 8.dp)
|
||||
.zIndex(3f)
|
||||
) {
|
||||
floatingDateText?.let { dateText ->
|
||||
DateHeader(
|
||||
dateText = dateText,
|
||||
textColor = dateHeaderTextColor,
|
||||
backgroundColor =
|
||||
dateHeaderBackgroundColor,
|
||||
verticalPadding = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
|
||||
val avatarInsetPx =
|
||||
with(density) {
|
||||
@@ -3568,7 +3677,8 @@ fun ChatDetailScreen(
|
||||
InAppCameraScreen(
|
||||
onDismiss = { showInAppCamera = false },
|
||||
onPhotoTaken = { photoUri ->
|
||||
// Сначала редактор (skipEnterAnimation=1f), потом убираем камеру
|
||||
// После камеры открываем тот же fullscreen-редактор,
|
||||
// что и для фото из галереи.
|
||||
pendingCameraPhotoUri = photoUri
|
||||
showInAppCamera = false
|
||||
}
|
||||
@@ -3577,26 +3687,25 @@ fun ChatDetailScreen(
|
||||
|
||||
// 📷 Image Editor для фото с камеры (с caption как в Telegram)
|
||||
pendingCameraPhotoUri?.let { uri ->
|
||||
ImageEditorScreen(
|
||||
SimpleFullscreenPhotoOverlay(
|
||||
imageUri = uri,
|
||||
onDismiss = {
|
||||
pendingCameraPhotoUri = null
|
||||
inputFocusTrigger++
|
||||
},
|
||||
onSave = { editedUri ->
|
||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||
viewModel.sendImageFromUri(editedUri, "")
|
||||
showMediaPicker = false
|
||||
},
|
||||
onSaveWithCaption = { editedUri, caption ->
|
||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||
modifier = Modifier.fillMaxSize().zIndex(100f),
|
||||
showCaptionInput = true,
|
||||
caption = pendingCameraPhotoCaption,
|
||||
onCaptionChange = { pendingCameraPhotoCaption = it },
|
||||
isDarkTheme = isDarkTheme,
|
||||
onSend = { editedUri, caption ->
|
||||
viewModel.sendImageFromUri(editedUri, caption)
|
||||
showMediaPicker = false
|
||||
pendingCameraPhotoUri = null
|
||||
pendingCameraPhotoCaption = ""
|
||||
inputFocusTrigger++
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
showCaptionInput = true,
|
||||
recipientName = user.title,
|
||||
skipEnterAnimation = true // Из камеры — мгновенно, без fade
|
||||
onDismiss = {
|
||||
pendingCameraPhotoUri = null
|
||||
pendingCameraPhotoCaption = ""
|
||||
inputFocusTrigger++
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3676,60 +3785,14 @@ private fun GroupMembersSubtitleSkeleton() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TiledChatWallpaper(
|
||||
private fun ChatWallpaperBackground(
|
||||
wallpaperResId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
tileScale: Float = 0.9f
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val wallpaperDrawable =
|
||||
remember(wallpaperResId, tileScale, context) {
|
||||
val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId)
|
||||
val normalizedScale = tileScale.coerceIn(0.2f, 2f)
|
||||
|
||||
val scaledBitmap =
|
||||
decoded?.let { original ->
|
||||
if (normalizedScale == 1f) {
|
||||
original
|
||||
} else {
|
||||
val width =
|
||||
(original.width * normalizedScale)
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val height =
|
||||
(original.height * normalizedScale)
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val scaled =
|
||||
Bitmap.createScaledBitmap(
|
||||
original,
|
||||
width,
|
||||
height,
|
||||
true
|
||||
)
|
||||
if (scaled != original) {
|
||||
original.recycle()
|
||||
}
|
||||
scaled
|
||||
}
|
||||
}
|
||||
|
||||
val safeBitmap =
|
||||
scaledBitmap
|
||||
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||
.apply {
|
||||
eraseColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
BitmapDrawable(context.resources, safeBitmap).apply {
|
||||
setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
Image(
|
||||
painter = painterResource(id = wallpaperResId),
|
||||
contentDescription = "Chat wallpaper",
|
||||
modifier = modifier,
|
||||
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } },
|
||||
update = { view -> view.background = wallpaperDrawable }
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2169,15 +2169,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
||||
fun deleteMessage(messageId: String) {
|
||||
val account = myPublicKey ?: return
|
||||
val opponent = opponentKey ?: return
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
|
||||
// Удаляем из UI сразу на main
|
||||
_messages.value = _messages.value.filter { it.id != messageId }
|
||||
val updatedMessages = _messages.value.filter { it.id != messageId }
|
||||
_messages.value = updatedMessages
|
||||
// Синхронизируем глобальный кэш диалога, иначе удалённые сообщения могут вернуться
|
||||
// при повторном открытии чата из stale cache.
|
||||
updateCacheWithLimit(account, dialogKey, updatedMessages)
|
||||
messageRepository.clearDialogCache(opponent)
|
||||
|
||||
// Удаляем из БД в IO + удаляем pin если был
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val account = myPublicKey ?: return@launch
|
||||
val dialogKey = opponentKey ?: return@launch
|
||||
pinnedMessageDao.removePin(account, dialogKey, messageId)
|
||||
messageDao.deleteMessage(account, messageId)
|
||||
if (account == opponent) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(account)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(account, opponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3500,15 +3512,101 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
val recipient = opponentKey
|
||||
val sender = myPublicKey
|
||||
val privateKey = myPrivateKey
|
||||
val context = getApplication<Application>()
|
||||
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
|
||||
|
||||
if (recipient == null || sender == null || privateKey == null) {
|
||||
ProtocolManager.addLog(
|
||||
"❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isSending) {
|
||||
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
|
||||
return
|
||||
}
|
||||
|
||||
isSending = true
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
val attachmentIds = imageUris.indices.map { index -> "img_${timestamp}_$index" }
|
||||
|
||||
val optimisticAttachments =
|
||||
imageUris.mapIndexed { index, uri ->
|
||||
MessageAttachment(
|
||||
id = attachmentIds[index],
|
||||
blob = "",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = "",
|
||||
width = 0,
|
||||
height = 0,
|
||||
localUri = uri.toString()
|
||||
)
|
||||
}
|
||||
|
||||
addMessageSafely(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments = optimisticAttachments
|
||||
)
|
||||
)
|
||||
_inputText.value = ""
|
||||
|
||||
ProtocolManager.addLog(
|
||||
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
|
||||
)
|
||||
|
||||
backgroundUploadScope.launch {
|
||||
try {
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray().apply {
|
||||
imageUris.forEachIndexed { index, uri ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentIds[index])
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", "")
|
||||
put("blob", "")
|
||||
put("width", 0)
|
||||
put("height", 0)
|
||||
put("localUri", uri.toString())
|
||||
}
|
||||
)
|
||||
}
|
||||
}.toString()
|
||||
|
||||
saveMessageToDatabase(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = "",
|
||||
encryptedKey = "",
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
|
||||
saveDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
|
||||
timestamp = timestamp,
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)")
|
||||
}
|
||||
|
||||
val preparedImages =
|
||||
imageUris.mapIndexedNotNull { index, uri ->
|
||||
imageUris.mapIndexed { index, uri ->
|
||||
val (width, height) =
|
||||
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
|
||||
context,
|
||||
@@ -3523,7 +3621,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ProtocolManager.addLog(
|
||||
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
|
||||
)
|
||||
return@mapIndexedNotNull null
|
||||
throw IllegalStateException(
|
||||
"group item#$index base64 conversion failed"
|
||||
)
|
||||
}
|
||||
val blurhash =
|
||||
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
|
||||
@@ -3533,26 +3633,156 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ProtocolManager.addLog(
|
||||
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
|
||||
)
|
||||
ImageData(
|
||||
base64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
index to
|
||||
ImageData(
|
||||
base64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
}
|
||||
|
||||
if (preparedImages.isEmpty()) {
|
||||
ProtocolManager.addLog(
|
||||
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
|
||||
)
|
||||
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||
isSending = false
|
||||
return@launch
|
||||
}
|
||||
ProtocolManager.addLog(
|
||||
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
sendImageGroup(preparedImages, caption)
|
||||
try {
|
||||
val groupStartedAt = System.currentTimeMillis()
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = recipient,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
val isSavedMessages = sender == recipient
|
||||
|
||||
val networkAttachments = mutableListOf<MessageAttachment>()
|
||||
val finalDbAttachments = JSONArray()
|
||||
val finalAttachmentsById = mutableMapOf<String, MessageAttachment>()
|
||||
|
||||
for ((originalIndex, imageData) in preparedImages) {
|
||||
val attachmentId = attachmentIds[originalIndex]
|
||||
val encryptedImageBlob =
|
||||
encryptAttachmentPayload(imageData.base64, encryptionContext)
|
||||
val uploadTag =
|
||||
if (!isSavedMessages) {
|
||||
TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewWithTag =
|
||||
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}"
|
||||
else imageData.blurhash
|
||||
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = imageData.base64,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sender,
|
||||
privateKey = privateKey
|
||||
)
|
||||
|
||||
val finalAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewWithTag,
|
||||
width = imageData.width,
|
||||
height = imageData.height,
|
||||
localUri = ""
|
||||
)
|
||||
networkAttachments.add(finalAttachment)
|
||||
finalAttachmentsById[attachmentId] = finalAttachment
|
||||
|
||||
finalDbAttachments.put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("blob", "")
|
||||
put("width", imageData.width)
|
||||
put("height", imageData.height)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val packet =
|
||||
PacketMessage().apply {
|
||||
fromPublicKey = sender
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
attachments = networkAttachments
|
||||
}
|
||||
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
|
||||
updateMessageStatusAndAttachmentsInDb(
|
||||
messageId = messageId,
|
||||
delivered = 1,
|
||||
attachmentsJson = finalDbAttachments.toString()
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_messages.value =
|
||||
_messages.value.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
msg.copy(
|
||||
status = MessageStatus.SENT,
|
||||
attachments =
|
||||
msg.attachments.map { current ->
|
||||
val final = finalAttachmentsById[current.id]
|
||||
if (final != null) {
|
||||
current.copy(
|
||||
preview = final.preview,
|
||||
width = final.width,
|
||||
height = final.height,
|
||||
localUri = ""
|
||||
)
|
||||
} else {
|
||||
current.copy(localUri = "")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
updateCacheFromCurrentMessages()
|
||||
}
|
||||
|
||||
saveDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
|
||||
timestamp = timestamp,
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
logPhotoPipeline(
|
||||
messageId,
|
||||
"group-from-uri completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logPhotoPipelineError(messageId, "group-from-uri", e)
|
||||
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||
} finally {
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ import compose.icons.tablericons.*
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.math.hypot
|
||||
@@ -108,6 +109,8 @@ private enum class DeviceResolveAction {
|
||||
DECLINE
|
||||
}
|
||||
|
||||
private const val SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||
|
||||
// Avatar colors matching React Native app (Mantine inspired)
|
||||
// Light theme colors (background lighter, text darker)
|
||||
private val avatarColorsLight =
|
||||
@@ -480,6 +483,9 @@ fun ChatsListScreen(
|
||||
it.status ==
|
||||
com.rosetta.messenger.network.FileDownloadStatus
|
||||
.DOWNLOADING ||
|
||||
it.status ==
|
||||
com.rosetta.messenger.network.FileDownloadStatus
|
||||
.PAUSED ||
|
||||
it.status ==
|
||||
com.rosetta.messenger.network.FileDownloadStatus
|
||||
.DECRYPTING
|
||||
@@ -1791,7 +1797,29 @@ fun ChatsListScreen(
|
||||
val requests = chatsState.requests
|
||||
val requestsCount = chatsState.requestsCount
|
||||
|
||||
val showSkeleton = isLoading
|
||||
val showSkeleton by
|
||||
produceState(
|
||||
initialValue = false,
|
||||
key1 = isLoading
|
||||
) {
|
||||
if (!isLoading) {
|
||||
value = false
|
||||
return@produceState
|
||||
}
|
||||
delay(SKELETON_VISIBILITY_DELAY_MS)
|
||||
value = isLoading
|
||||
}
|
||||
val chatListState = rememberLazyListState()
|
||||
var isRequestsVisible by remember { mutableStateOf(true) }
|
||||
var requestsPullProgress by remember { mutableStateOf(0f) }
|
||||
var lastAutoScrolledVerificationId by remember {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
val localDensity = LocalDensity.current
|
||||
val requestsRevealThresholdPx =
|
||||
remember(localDensity) { with(localDensity) { 28.dp.toPx() } }
|
||||
val requestsHideThresholdPx =
|
||||
remember(localDensity) { with(localDensity) { 16.dp.toPx() } }
|
||||
|
||||
AnimatedContent(
|
||||
targetState = showDownloadsScreen,
|
||||
@@ -1999,6 +2027,8 @@ fun ChatsListScreen(
|
||||
} // Close Box wrapper
|
||||
} else if (showSkeleton) {
|
||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||
} else if (isLoading && chatsState.isEmpty) {
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
} else if (chatsState.isEmpty) {
|
||||
EmptyChatsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -2056,14 +2086,6 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Track scroll direction to hide/show Requests
|
||||
val chatListState = rememberLazyListState()
|
||||
var isRequestsVisible by remember { mutableStateOf(true) }
|
||||
var lastAutoScrolledVerificationId by remember {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
// When a new device confirmation banner appears at the top,
|
||||
// smoothly bring the list to top so the banner is visible.
|
||||
LaunchedEffect(pendingDeviceVerification?.deviceId) {
|
||||
@@ -2087,44 +2109,135 @@ fun ChatsListScreen(
|
||||
lastAutoScrolledVerificationId = verificationId
|
||||
}
|
||||
|
||||
// NestedScroll — ловим направление свайпа даже без скролла
|
||||
// Для появления: накапливаем pull down дельту, нужен сильный жест
|
||||
val requestsNestedScroll = remember(hapticFeedback) {
|
||||
var accumulatedPullDown = 0f
|
||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: androidx.compose.ui.geometry.Offset,
|
||||
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
): androidx.compose.ui.geometry.Offset {
|
||||
if (available.y < -10f) {
|
||||
// Свайп вверх — прячем легко
|
||||
accumulatedPullDown = 0f
|
||||
isRequestsVisible = false
|
||||
} else if (available.y > 0f && !isRequestsVisible) {
|
||||
// Свайп вниз — накапливаем для появления
|
||||
accumulatedPullDown += available.y
|
||||
if (accumulatedPullDown > 120f) {
|
||||
isRequestsVisible = true
|
||||
val requestsNestedScroll =
|
||||
remember(
|
||||
requestsCount,
|
||||
chatListState,
|
||||
requestsRevealThresholdPx,
|
||||
requestsHideThresholdPx,
|
||||
hapticFeedback
|
||||
) {
|
||||
var accumulatedPullDown = 0f
|
||||
var accumulatedPullUp = 0f
|
||||
val pullDownLimit =
|
||||
requestsRevealThresholdPx * 1.25f
|
||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: androidx.compose.ui.geometry.Offset,
|
||||
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
): androidx.compose.ui.geometry.Offset {
|
||||
if (source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag ||
|
||||
requestsCount <= 0
|
||||
) {
|
||||
accumulatedPullDown = 0f
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
accumulatedPullUp = 0f
|
||||
requestsPullProgress = 0f
|
||||
return androidx.compose.ui.geometry.Offset.Zero
|
||||
}
|
||||
|
||||
val atTop = !chatListState.canScrollBackward
|
||||
val nearTop =
|
||||
chatListState.firstVisibleItemIndex == 0 &&
|
||||
chatListState.firstVisibleItemScrollOffset <=
|
||||
2
|
||||
|
||||
if (available.y < 0f &&
|
||||
isRequestsVisible &&
|
||||
(atTop ||
|
||||
nearTop)
|
||||
) {
|
||||
accumulatedPullUp += -available.y
|
||||
accumulatedPullDown = 0f
|
||||
requestsPullProgress = 0f
|
||||
if (accumulatedPullUp >= requestsHideThresholdPx) {
|
||||
isRequestsVisible = false
|
||||
accumulatedPullUp = 0f
|
||||
}
|
||||
return androidx.compose.ui.geometry.Offset(
|
||||
0f,
|
||||
available.y
|
||||
)
|
||||
}
|
||||
} else if (available.y <= 0f) {
|
||||
accumulatedPullDown = 0f
|
||||
}
|
||||
return androidx.compose.ui.geometry.Offset.Zero
|
||||
}
|
||||
if (available.y >= 0f) {
|
||||
accumulatedPullUp = 0f
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(
|
||||
consumed: androidx.compose.ui.unit.Velocity,
|
||||
available: androidx.compose.ui.unit.Velocity
|
||||
): androidx.compose.ui.unit.Velocity {
|
||||
accumulatedPullDown = 0f
|
||||
return androidx.compose.ui.unit.Velocity.Zero
|
||||
if (!isRequestsVisible && atTop) {
|
||||
if (available.y > 0f) {
|
||||
accumulatedPullDown =
|
||||
(accumulatedPullDown + available.y)
|
||||
.coerceAtMost(
|
||||
pullDownLimit
|
||||
)
|
||||
requestsPullProgress =
|
||||
(accumulatedPullDown / requestsRevealThresholdPx)
|
||||
.coerceIn(
|
||||
0f,
|
||||
1.15f
|
||||
)
|
||||
if (accumulatedPullDown >= requestsRevealThresholdPx) {
|
||||
isRequestsVisible = true
|
||||
accumulatedPullDown = 0f
|
||||
accumulatedPullUp = 0f
|
||||
requestsPullProgress = 0f
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
)
|
||||
}
|
||||
return androidx.compose.ui.geometry.Offset(
|
||||
0f,
|
||||
available.y
|
||||
)
|
||||
}
|
||||
|
||||
if (available.y < 0f &&
|
||||
accumulatedPullDown > 0f
|
||||
) {
|
||||
accumulatedPullDown =
|
||||
(accumulatedPullDown + available.y)
|
||||
.coerceAtLeast(
|
||||
0f
|
||||
)
|
||||
requestsPullProgress =
|
||||
(accumulatedPullDown / requestsRevealThresholdPx)
|
||||
.coerceIn(
|
||||
0f,
|
||||
1.15f
|
||||
)
|
||||
return androidx.compose.ui.geometry.Offset(
|
||||
0f,
|
||||
available.y
|
||||
)
|
||||
}
|
||||
} else if (!isRequestsVisible && !atTop) {
|
||||
accumulatedPullDown = 0f
|
||||
requestsPullProgress = 0f
|
||||
}
|
||||
|
||||
return androidx.compose.ui.geometry.Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(
|
||||
available: androidx.compose.ui.unit.Velocity
|
||||
): androidx.compose.ui.unit.Velocity {
|
||||
if (!isRequestsVisible) {
|
||||
accumulatedPullDown = 0f
|
||||
requestsPullProgress = 0f
|
||||
}
|
||||
return androidx.compose.ui.unit.Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(
|
||||
consumed: androidx.compose.ui.unit.Velocity,
|
||||
available: androidx.compose.ui.unit.Velocity
|
||||
): androidx.compose.ui.unit.Velocity {
|
||||
accumulatedPullDown = 0f
|
||||
accumulatedPullUp = 0f
|
||||
requestsPullProgress = 0f
|
||||
return androidx.compose.ui.unit.Velocity.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = chatListState,
|
||||
@@ -2163,102 +2276,96 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "requests_section") {
|
||||
val isRequestsSectionVisible =
|
||||
requestsCount > 0 &&
|
||||
isRequestsVisible
|
||||
AnimatedVisibility(
|
||||
visible =
|
||||
isRequestsSectionVisible,
|
||||
enter =
|
||||
slideInVertically(
|
||||
initialOffsetY = {
|
||||
fullHeight ->
|
||||
-fullHeight /
|
||||
3
|
||||
},
|
||||
if (requestsCount > 0) {
|
||||
item(key = "requests_section") {
|
||||
val requestsSectionProgress by
|
||||
animateFloatAsState(
|
||||
targetValue =
|
||||
if (isRequestsVisible) 1f
|
||||
else requestsPullProgress,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
260,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) +
|
||||
expandVertically(
|
||||
expandFrom =
|
||||
Alignment
|
||||
.Top,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
260,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) +
|
||||
fadeIn(
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
180
|
||||
),
|
||||
initialAlpha =
|
||||
0.7f
|
||||
),
|
||||
exit =
|
||||
slideOutVertically(
|
||||
targetOffsetY = {
|
||||
fullHeight ->
|
||||
-fullHeight /
|
||||
3
|
||||
},
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
220,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) +
|
||||
shrinkVertically(
|
||||
shrinkTowards =
|
||||
Alignment
|
||||
.Top,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
220,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) +
|
||||
fadeOut(
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
140
|
||||
spring(
|
||||
dampingRatio =
|
||||
Spring.DampingRatioNoBouncy,
|
||||
stiffness =
|
||||
Spring.StiffnessMediumLow
|
||||
),
|
||||
label =
|
||||
"requestsSectionProgress"
|
||||
)
|
||||
val clampedProgress =
|
||||
requestsSectionProgress
|
||||
.coerceIn(
|
||||
0f,
|
||||
1.15f
|
||||
)
|
||||
val revealProgress =
|
||||
FastOutSlowInEasing
|
||||
.transform(
|
||||
clampedProgress
|
||||
.coerceIn(
|
||||
0f,
|
||||
1f
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
RequestsSection(
|
||||
count =
|
||||
requestsCount,
|
||||
requests =
|
||||
requests,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
onClick = {
|
||||
openRequestsRouteSafely()
|
||||
val stretchOvershoot =
|
||||
(clampedProgress - 1f)
|
||||
.coerceAtLeast(
|
||||
0f
|
||||
)
|
||||
val sectionHeight =
|
||||
76.dp *
|
||||
revealProgress +
|
||||
10.dp *
|
||||
stretchOvershoot
|
||||
val sectionAlpha =
|
||||
(0.55f + revealProgress * 0.45f)
|
||||
.coerceIn(
|
||||
0f,
|
||||
1f
|
||||
)
|
||||
|
||||
if (isRequestsVisible ||
|
||||
sectionHeight > 0.5.dp
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.height(
|
||||
sectionHeight
|
||||
)
|
||||
.clipToBounds()
|
||||
.graphicsLayer {
|
||||
alpha =
|
||||
if (isRequestsVisible)
|
||||
1f
|
||||
else sectionAlpha
|
||||
}
|
||||
) {
|
||||
RequestsSection(
|
||||
count =
|
||||
requestsCount,
|
||||
requests =
|
||||
requests,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
onClick = {
|
||||
isRequestsVisible =
|
||||
true
|
||||
requestsPullProgress =
|
||||
0f
|
||||
openRequestsRouteSafely()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
Divider(
|
||||
color =
|
||||
dividerColor,
|
||||
thickness =
|
||||
0.5.dp
|
||||
)
|
||||
Divider(
|
||||
color =
|
||||
dividerColor,
|
||||
thickness =
|
||||
0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2332,16 +2439,7 @@ fun ChatsListScreen(
|
||||
listBackgroundColor
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.animateItemPlacement(
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
SwipeableDialogItem(
|
||||
dialog =
|
||||
dialog,
|
||||
@@ -4383,17 +4481,17 @@ fun DialogItemContent(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(22.dp)
|
||||
Modifier.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFE53935)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "!",
|
||||
fontSize = 13.sp,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
lineHeight = 13.sp,
|
||||
lineHeight = 10.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
@@ -4962,6 +5060,7 @@ private fun formatDownloadStatusText(
|
||||
return when (item.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> "Paused $percent%"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"
|
||||
|
||||
@@ -137,6 +137,7 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
||||
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
|
||||
@@ -339,6 +340,7 @@ fun GroupInfoScreen(
|
||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2)
|
||||
val groupMenuTrailingIconSize = 22.dp
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
@@ -805,8 +807,8 @@ fun GroupInfoScreen(
|
||||
swipedMemberKey = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(swipedMemberKey) {
|
||||
onSwipeBackEnabledChanged(swipedMemberKey == null)
|
||||
LaunchedEffect(swipedMemberKey, showEncryptionPage) {
|
||||
onSwipeBackEnabledChanged(swipedMemberKey == null && !showEncryptionPage)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
@@ -1207,7 +1209,7 @@ fun GroupInfoScreen(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
modifier = Modifier.size(groupMenuTrailingIconSize)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1233,7 +1235,7 @@ fun GroupInfoScreen(
|
||||
)
|
||||
if (encryptionKeyLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Modifier.size(groupMenuTrailingIconSize),
|
||||
strokeWidth = 2.dp,
|
||||
color = accentColor
|
||||
)
|
||||
@@ -1241,12 +1243,12 @@ fun GroupInfoScreen(
|
||||
val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.size(groupMenuTrailingIconSize)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
) {
|
||||
TelegramStyleIdenticon(
|
||||
keyRender = identiconKey,
|
||||
size = 34.dp,
|
||||
size = groupMenuTrailingIconSize,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
@@ -1565,11 +1567,12 @@ fun GroupInfoScreen(
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showEncryptionPage,
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
SwipeBackContainer(
|
||||
isVisible = showEncryptionPage,
|
||||
onBack = { showEncryptionPage = false },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 3,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
||||
GroupEncryptionKeyPage(
|
||||
|
||||
@@ -62,6 +62,7 @@ internal fun MediaGrid(
|
||||
mediaItems: List<MediaItem>,
|
||||
selectedItemOrder: List<Long>,
|
||||
showCameraItem: Boolean = true,
|
||||
cameraEnabled: Boolean = true,
|
||||
gridState: LazyGridState = rememberLazyGridState(),
|
||||
onCameraClick: () -> Unit,
|
||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||
@@ -87,6 +88,7 @@ internal fun MediaGrid(
|
||||
item(key = "camera_button") {
|
||||
CameraGridItem(
|
||||
onClick = onCameraClick,
|
||||
enabled = cameraEnabled,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
@@ -117,6 +119,7 @@ internal fun MediaGrid(
|
||||
@Composable
|
||||
internal fun CameraGridItem(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -147,7 +150,9 @@ internal fun CameraGridItem(
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
||||
val enabledState = rememberUpdatedState(enabled)
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||
onDispose {
|
||||
val provider = cameraProvider
|
||||
val preview = previewUseCase
|
||||
@@ -167,10 +172,10 @@ internal fun CameraGridItem(
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasCameraPermission) {
|
||||
if (hasCameraPermission && enabled) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx).apply {
|
||||
@@ -181,6 +186,9 @@ internal fun CameraGridItem(
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
if (!enabledState.value) {
|
||||
return@addListener
|
||||
}
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
val preview = Preview.Builder().build().also {
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
|
||||
internal fun AttachAlertPhotoLayout(
|
||||
state: AttachAlertUiState,
|
||||
gridState: LazyGridState,
|
||||
cameraEnabled: Boolean = true,
|
||||
onCameraClick: () -> Unit,
|
||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||
onItemCheckClick: (MediaItem) -> Unit,
|
||||
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
|
||||
mediaItems = state.visibleMediaItems,
|
||||
selectedItemOrder = state.selectedItemOrder,
|
||||
showCameraItem = state.visibleAlbum?.isAllMedia != false,
|
||||
cameraEnabled = cameraEnabled,
|
||||
gridState = gridState,
|
||||
onCameraClick = onCameraClick,
|
||||
onItemClick = onItemClick,
|
||||
|
||||
@@ -122,13 +122,6 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Telegram-style attach alert (media picker bottom sheet).
|
||||
*
|
||||
@@ -741,52 +734,49 @@ fun ChatAttachAlert(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldShow, state.editingItem) {
|
||||
LaunchedEffect(
|
||||
shouldShow,
|
||||
state.editingItem,
|
||||
isPickerFullScreen,
|
||||
isDarkTheme,
|
||||
hasNativeNavigationBar
|
||||
) {
|
||||
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
// Apply scrim to status bar so it matches the overlay darkness
|
||||
val scrimInt = (alpha * 255).toInt().coerceIn(0, 255)
|
||||
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
if (hasNativeNavigationBar) {
|
||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
val dark = isDarkTheme
|
||||
val fullScreen = isPickerFullScreen
|
||||
|
||||
if (hasNativeNavigationBar) {
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
window.navigationBarColor = navBaseColor
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
|
||||
// Telegram-like natural dim: status-bar tint follows the same scrim alpha
|
||||
// as the popup overlay, so top area and content overlay always match.
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
var lastAppliedAlpha = -1
|
||||
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
|
||||
.collect { alpha ->
|
||||
if (alpha != lastAppliedAlpha) {
|
||||
lastAppliedAlpha = alpha
|
||||
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1062,6 +1052,7 @@ fun ChatAttachAlert(
|
||||
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
|
||||
state = state,
|
||||
gridState = mediaGridState,
|
||||
cameraEnabled = !isClosing,
|
||||
onCameraClick = {
|
||||
requestClose {
|
||||
hideKeyboard()
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.content.FileProvider
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
@@ -333,6 +341,105 @@ enum class DownloadStatus {
|
||||
ERROR
|
||||
}
|
||||
|
||||
private enum class TelegramFileActionState {
|
||||
FILE,
|
||||
DOWNLOAD,
|
||||
CANCEL,
|
||||
PAUSE,
|
||||
ERROR
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TelegramFileActionButton(
|
||||
state: TelegramFileActionState,
|
||||
progress: Float?,
|
||||
indeterminate: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spinTransition = rememberInfiniteTransition(label = "file_action_spin")
|
||||
val spin by spinTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "file_action_spin_progress"
|
||||
)
|
||||
|
||||
val backgroundColor = if (state == TelegramFileActionState.ERROR) Color(0xFFE53935) else PrimaryBlue
|
||||
val iconPainter = when (state) {
|
||||
TelegramFileActionState.FILE -> TelegramIcons.File
|
||||
TelegramFileActionState.DOWNLOAD -> painterResource(R.drawable.msg_download)
|
||||
TelegramFileActionState.CANCEL -> TelegramIcons.Close
|
||||
TelegramFileActionState.ERROR -> TelegramIcons.Close
|
||||
TelegramFileActionState.PAUSE -> null
|
||||
}
|
||||
val iconSize =
|
||||
when (state) {
|
||||
TelegramFileActionState.ERROR -> 18.dp
|
||||
TelegramFileActionState.PAUSE -> 18.dp
|
||||
else -> 20.dp
|
||||
}
|
||||
|
||||
val showProgressRing =
|
||||
(state == TelegramFileActionState.PAUSE ||
|
||||
state == TelegramFileActionState.DOWNLOAD ||
|
||||
state == TelegramFileActionState.CANCEL) &&
|
||||
(indeterminate || progress != null)
|
||||
val sweep = when {
|
||||
indeterminate -> 104f
|
||||
progress != null -> (progress.coerceIn(0f, 1f) * 360f)
|
||||
else -> 0f
|
||||
}
|
||||
val startAngle = if (indeterminate) (spin * 360f) - 90f else -90f
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (showProgressRing && sweep > 0f) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
val inset = 3.dp.toPx()
|
||||
drawArc(
|
||||
color = Color.White,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweep,
|
||||
useCenter = false,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = Size(size.width - inset * 2f, size.height - inset * 2f),
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state == TelegramFileActionState.PAUSE) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Pause,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = iconPainter ?: TelegramIcons.File,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
|
||||
* коллаж (как в Telegram)
|
||||
@@ -1454,6 +1561,8 @@ fun FileAttachment(
|
||||
val context = LocalContext.current
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
var downloadProgress by remember { mutableStateOf(0f) }
|
||||
var isPaused by remember { mutableStateOf(false) }
|
||||
var lastActionAtMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// Bounce animation for icon
|
||||
val iconScale = remember { Animatable(0f) }
|
||||
@@ -1495,16 +1604,40 @@ fun FileAttachment(
|
||||
downloadStatus = when (state.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||
}
|
||||
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||
}
|
||||
|
||||
LaunchedEffect(attachment.id) {
|
||||
val existingState = com.rosetta.messenger.network.FileDownloadManager.stateOf(attachment.id)
|
||||
if (existingState != null) {
|
||||
downloadProgress = existingState.progress
|
||||
downloadStatus = when (existingState.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING ->
|
||||
DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.PAUSED ->
|
||||
DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING ->
|
||||
DownloadStatus.DECRYPTING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE ->
|
||||
DownloadStatus.DOWNLOADED
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR ->
|
||||
DownloadStatus.ERROR
|
||||
}
|
||||
isPaused =
|
||||
existingState.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
isPaused = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
@@ -1516,6 +1649,7 @@ fun FileAttachment(
|
||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
||||
}
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
// Открыть файл через системное приложение
|
||||
@@ -1551,10 +1685,8 @@ fun FileAttachment(
|
||||
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||
val download: () -> Unit = {
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
downloadProgress = 0f
|
||||
isPaused = false
|
||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||
context = context,
|
||||
attachmentId = attachment.id,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
@@ -1566,19 +1698,56 @@ fun FileAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
val pauseDownload: () -> Unit = {
|
||||
com.rosetta.messenger.network.FileDownloadManager.pause(attachment.id)
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
val resumeDownload: () -> Unit = {
|
||||
isPaused = false
|
||||
com.rosetta.messenger.network.FileDownloadManager.resume(attachment.id)
|
||||
}
|
||||
|
||||
val isSendingUpload = isOutgoing && messageStatus == MessageStatus.SENDING
|
||||
val isDownloadInProgress =
|
||||
!isPaused &&
|
||||
(downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||
downloadStatus == DownloadStatus.DECRYPTING)
|
||||
val actionState = when {
|
||||
downloadStatus == DownloadStatus.ERROR -> TelegramFileActionState.ERROR
|
||||
isSendingUpload -> TelegramFileActionState.CANCEL
|
||||
isDownloadInProgress -> TelegramFileActionState.PAUSE
|
||||
isPaused -> TelegramFileActionState.DOWNLOAD
|
||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED -> TelegramFileActionState.DOWNLOAD
|
||||
else -> TelegramFileActionState.FILE
|
||||
}
|
||||
val actionProgress = if (isDownloadInProgress || isPaused) animatedProgress else null
|
||||
|
||||
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
enabled =
|
||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.ERROR
|
||||
!isSendingUpload &&
|
||||
(downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.ERROR ||
|
||||
isDownloadInProgress ||
|
||||
isPaused)
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADED -> openFile()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastActionAtMs < 220L) return@clickable
|
||||
lastActionAtMs = now
|
||||
|
||||
when {
|
||||
isPaused -> resumeDownload()
|
||||
downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
|
||||
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
|
||||
else -> download()
|
||||
}
|
||||
}
|
||||
@@ -1595,62 +1764,12 @@ fun FileAttachment(
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Круглый фон иконки
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (downloadStatus == DownloadStatus.ERROR)
|
||||
Color(0xFFE53935)
|
||||
else PrimaryBlue
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
// Determinate progress like Telegram
|
||||
CircularProgressIndicator(
|
||||
progress = downloadProgress.coerceIn(0f, 1f),
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
DownloadStatus.NOT_DOWNLOADED -> {
|
||||
Icon(
|
||||
Icons.Default.ArrowDownward,
|
||||
contentDescription = "Download",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DownloadStatus.DOWNLOADED -> {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DownloadStatus.ERROR -> {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Error",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TelegramFileActionButton(
|
||||
state = actionState,
|
||||
progress = actionProgress,
|
||||
indeterminate = isSendingUpload,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
@@ -1679,34 +1798,52 @@ fun FileAttachment(
|
||||
PrimaryBlue
|
||||
}
|
||||
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||
// CDN download maps to progress 0..0.8
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
DownloadStatus.DECRYPTING -> {
|
||||
AnimatedDotsText(
|
||||
baseText = "Decrypting",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = when (downloadStatus) {
|
||||
DownloadStatus.ERROR -> "File expired"
|
||||
else -> "${formatFileSize(fileSize)} $fileExtension"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
if (isSendingUpload) {
|
||||
AnimatedDotsText(
|
||||
baseText = "Uploading",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
} else {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
if (isPaused) {
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
} else {
|
||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||
// CDN download maps to progress 0..0.8
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.DECRYPTING -> {
|
||||
AnimatedDotsText(
|
||||
baseText = "Decrypting",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = when (downloadStatus) {
|
||||
DownloadStatus.ERROR -> "File expired"
|
||||
else -> "${formatFileSize(fileSize)} $fileExtension"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
@@ -191,37 +192,33 @@ fun TelegramStyleMessageContent(
|
||||
|
||||
private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int)
|
||||
|
||||
/** Date header with fade-in animation */
|
||||
/** Telegram-like date header chip (inline separator and floating top badge). */
|
||||
@Composable
|
||||
fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
val alpha by
|
||||
animateFloatAsState(
|
||||
targetValue = if (isVisible) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
|
||||
label = "dateAlpha"
|
||||
)
|
||||
|
||||
LaunchedEffect(dateText) { isVisible = true }
|
||||
|
||||
fun DateHeader(
|
||||
dateText: String,
|
||||
textColor: Color,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
verticalPadding: Dp = 12.dp
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer {
|
||||
this.alpha = alpha
|
||||
},
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = verticalPadding),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = dateText,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = secondaryTextColor,
|
||||
color = textColor,
|
||||
modifier =
|
||||
Modifier.background(
|
||||
color = secondaryTextColor.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
color = backgroundColor,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -861,8 +858,14 @@ fun MessageBubble(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val senderLabelText =
|
||||
senderName
|
||||
.replace('\n', ' ')
|
||||
.trim()
|
||||
val senderLabelMaxWidth =
|
||||
if (isGroupSenderAdmin) 170.dp else 220.dp
|
||||
Text(
|
||||
text = senderName,
|
||||
text = senderLabelText,
|
||||
color =
|
||||
groupSenderLabelColor(
|
||||
senderPublicKey,
|
||||
@@ -870,6 +873,7 @@ fun MessageBubble(
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
@@ -82,13 +82,6 @@ import kotlin.math.roundToInt
|
||||
private const val TAG = "MediaPickerBottomSheet"
|
||||
private const val ALL_MEDIA_ALBUM_ID = 0L
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Media item from gallery
|
||||
*/
|
||||
@@ -606,56 +599,50 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive updates — single snapshotFlow drives ALL system bar colors
|
||||
LaunchedEffect(shouldShow, editingItem) {
|
||||
// Telegram-style: system bar updates only by picker state,
|
||||
// no per-frame status bar color animation.
|
||||
LaunchedEffect(
|
||||
shouldShow,
|
||||
editingItem,
|
||||
isPickerFullScreen,
|
||||
isDarkTheme,
|
||||
hasNativeNavigationBar
|
||||
) {
|
||||
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
||||
val dark = isDarkTheme
|
||||
val fullScreen = isPickerFullScreen
|
||||
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
// Full screen: status bar = picker background, seamless
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
// Collapsed: semi-transparent scrim
|
||||
window.statusBarColor = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
|
||||
)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
if (hasNativeNavigationBar) {
|
||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
if (hasNativeNavigationBar) {
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
window.navigationBarColor = navBaseColor
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
|
||||
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
var lastAppliedAlpha = -1
|
||||
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
|
||||
.collect { alpha ->
|
||||
if (alpha != lastAppliedAlpha) {
|
||||
lastAppliedAlpha = alpha
|
||||
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Используем Popup для показа поверх клавиатуры
|
||||
@@ -1047,6 +1034,7 @@ fun MediaPickerBottomSheet(
|
||||
mediaItems = visibleMediaItems,
|
||||
selectedItemOrder = selectedItemOrder,
|
||||
showCameraItem = selectedAlbum?.isAllMedia != false,
|
||||
cameraEnabled = !isClosing,
|
||||
gridState = mediaGridState,
|
||||
onCameraClick = {
|
||||
requestClose {
|
||||
@@ -1659,6 +1647,7 @@ private fun MediaGrid(
|
||||
mediaItems: List<MediaItem>,
|
||||
selectedItemOrder: List<Long>,
|
||||
showCameraItem: Boolean = true,
|
||||
cameraEnabled: Boolean = true,
|
||||
gridState: LazyGridState = rememberLazyGridState(),
|
||||
onCameraClick: () -> Unit,
|
||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||
@@ -1685,6 +1674,7 @@ private fun MediaGrid(
|
||||
item(key = "camera_button") {
|
||||
CameraGridItem(
|
||||
onClick = onCameraClick,
|
||||
enabled = cameraEnabled,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
@@ -1715,6 +1705,7 @@ private fun MediaGrid(
|
||||
@Composable
|
||||
private fun CameraGridItem(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -1753,7 +1744,9 @@ private fun CameraGridItem(
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
||||
val enabledState = rememberUpdatedState(enabled)
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||
onDispose {
|
||||
val provider = cameraProvider
|
||||
val preview = previewUseCase
|
||||
@@ -1773,10 +1766,10 @@ private fun CameraGridItem(
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasCameraPermission) {
|
||||
if (hasCameraPermission && enabled) {
|
||||
// Show live camera preview
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
@@ -1788,6 +1781,9 @@ private fun CameraGridItem(
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
if (!enabledState.value) {
|
||||
return@addListener
|
||||
}
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
@@ -20,9 +21,11 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||
@@ -174,6 +177,15 @@ fun SwipeBackContainer(
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val dismissKeyboard: () -> Unit = {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
imm.hideSoftInputFromWindow(view.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
(view.context as? Activity)?.window?.let { window ->
|
||||
WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
|
||||
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
@@ -333,6 +345,7 @@ fun SwipeBackContainer(
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
var keyboardHiddenForGesture = false
|
||||
|
||||
// deferToChildren=true: pre-slop uses Main pass so children
|
||||
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||
@@ -359,6 +372,14 @@ fun SwipeBackContainer(
|
||||
totalDragX += dragDelta.x
|
||||
totalDragY += dragDelta.y
|
||||
|
||||
if (!keyboardHiddenForGesture &&
|
||||
totalDragX > 10f &&
|
||||
kotlin.math.abs(totalDragX) >
|
||||
kotlin.math.abs(totalDragY)) {
|
||||
dismissKeyboard()
|
||||
keyboardHiddenForGesture = true
|
||||
}
|
||||
|
||||
if (!passedSlop) {
|
||||
// Child (e.g. LazyRow) already consumed — let it handle
|
||||
if (change.isConsumed) break
|
||||
@@ -393,17 +414,8 @@ fun SwipeBackContainer(
|
||||
screenWidthPx,
|
||||
active = true
|
||||
)
|
||||
|
||||
val imm =
|
||||
context.getSystemService(
|
||||
Context.INPUT_METHOD_SERVICE
|
||||
) as
|
||||
InputMethodManager
|
||||
imm.hideSoftInputFromWindow(
|
||||
view.windowToken,
|
||||
0
|
||||
)
|
||||
focusManager.clearFocus()
|
||||
dismissKeyboard()
|
||||
keyboardHiddenForGesture = true
|
||||
|
||||
change.consume()
|
||||
} else {
|
||||
@@ -489,6 +501,7 @@ fun SwipeBackContainer(
|
||||
shouldShow = false
|
||||
dragOffset = 0f
|
||||
clearSharedSwipeProgressIfOwner()
|
||||
dismissKeyboard()
|
||||
onBack()
|
||||
} else {
|
||||
offsetAnimatable.animateTo(
|
||||
|
||||
@@ -108,6 +108,7 @@ import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -199,19 +200,12 @@ fun OtherProfileScreen(
|
||||
var avatarViewerTimestamp by remember { mutableStateOf(0L) }
|
||||
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
||||
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
|
||||
var tabSwitchJob by remember { mutableStateOf<Job?>(null) }
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
pageCount = { OtherProfileTab.entries.size }
|
||||
)
|
||||
|
||||
// Tab click → animate pager
|
||||
LaunchedEffect(selectedTab) {
|
||||
val page = OtherProfileTab.entries.indexOf(selectedTab)
|
||||
if (pagerState.currentPage != page) {
|
||||
pagerState.animateScrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
// Pager swipe → update tab + control swipe-back
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
@@ -826,7 +820,15 @@ fun OtherProfileScreen(
|
||||
) {
|
||||
OtherProfileSharedTabs(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { tab -> selectedTab = tab },
|
||||
onTabSelected = { tab ->
|
||||
val targetPage = OtherProfileTab.entries.indexOf(tab)
|
||||
if (targetPage == -1) return@OtherProfileSharedTabs
|
||||
selectedTab = tab
|
||||
tabSwitchJob?.cancel()
|
||||
tabSwitchJob = coroutineScope.launch {
|
||||
runCatching { pagerState.animateScrollToPage(targetPage) }
|
||||
}
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ fun ThemeScreen(
|
||||
WallpaperSelectorRow(
|
||||
isDarkTheme = isDarkTheme,
|
||||
selectedWallpaperId = wallpaperId,
|
||||
wallpapers = ThemeWallpapers.forTheme(isDarkTheme),
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
onWallpaperSelected = { selectedId ->
|
||||
@@ -292,7 +293,12 @@ fun ThemeScreen(
|
||||
)
|
||||
|
||||
TelegramInfoText(
|
||||
text = "Selected wallpaper is used for chat backgrounds.",
|
||||
text =
|
||||
if (isDarkTheme) {
|
||||
"Showing wallpapers for dark theme. Switch to light mode to choose light wallpapers."
|
||||
} else {
|
||||
"Showing wallpapers for light theme. Switch to dark mode to choose dark wallpapers."
|
||||
},
|
||||
secondaryTextColor = secondaryTextColor
|
||||
)
|
||||
|
||||
@@ -454,6 +460,7 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
|
||||
private fun WallpaperSelectorRow(
|
||||
isDarkTheme: Boolean,
|
||||
selectedWallpaperId: String,
|
||||
wallpapers: List<ThemeWallpaper>,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
onWallpaperSelected: (String) -> Unit
|
||||
@@ -475,7 +482,7 @@ private fun WallpaperSelectorRow(
|
||||
)
|
||||
}
|
||||
|
||||
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
|
||||
items(items = wallpapers, key = { it.id }) { wallpaper ->
|
||||
WallpaperSelectorItem(
|
||||
title = wallpaper.name,
|
||||
wallpaperResId = wallpaper.drawableRes,
|
||||
|
||||
@@ -6,28 +6,94 @@ import com.rosetta.messenger.R
|
||||
data class ThemeWallpaper(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val preferredTheme: WallpaperTheme,
|
||||
@DrawableRes val drawableRes: Int
|
||||
)
|
||||
|
||||
enum class WallpaperTheme {
|
||||
DARK,
|
||||
LIGHT
|
||||
}
|
||||
|
||||
object ThemeWallpapers {
|
||||
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
|
||||
val all: List<ThemeWallpaper> =
|
||||
listOf(
|
||||
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3),
|
||||
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4),
|
||||
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5),
|
||||
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6),
|
||||
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7),
|
||||
ThemeWallpaper(id = "back_8", name = "Wallpaper 6", drawableRes = R.drawable.wallpaper_back_8),
|
||||
ThemeWallpaper(id = "back_9", name = "Wallpaper 7", drawableRes = R.drawable.wallpaper_back_9),
|
||||
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10),
|
||||
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11),
|
||||
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1),
|
||||
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2)
|
||||
ThemeWallpaper(
|
||||
id = "back_3",
|
||||
name = "Wallpaper 1",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
drawableRes = R.drawable.wallpaper_back_3
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_4",
|
||||
name = "Wallpaper 2",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_4
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_5",
|
||||
name = "Wallpaper 3",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
drawableRes = R.drawable.wallpaper_back_5
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_6",
|
||||
name = "Wallpaper 4",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_6
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_7",
|
||||
name = "Wallpaper 5",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_7
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_8",
|
||||
name = "Wallpaper 6",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_8
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_9",
|
||||
name = "Wallpaper 7",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_9
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_10",
|
||||
name = "Wallpaper 8",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_10
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_11",
|
||||
name = "Wallpaper 9",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
drawableRes = R.drawable.wallpaper_back_11
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_1",
|
||||
name = "Wallpaper 10",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
drawableRes = R.drawable.wallpaper_back_1
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_2",
|
||||
name = "Wallpaper 11",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
drawableRes = R.drawable.wallpaper_back_2
|
||||
)
|
||||
)
|
||||
|
||||
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
|
||||
|
||||
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
|
||||
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
||||
return all.filter { it.preferredTheme == targetTheme }
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user