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
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.2.3"
|
val rosettaVersionName = "1.2.4"
|
||||||
val rosettaVersionCode = 25 // Increment on each release
|
val rosettaVersionCode = 26 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -19,14 +19,48 @@ object ReleaseNotes {
|
|||||||
|
|
||||||
Что обновлено после версии 1.2.3
|
Что обновлено после версии 1.2.3
|
||||||
|
|
||||||
Группы и медиа
|
Чат-лист и Requests
|
||||||
- Исправлено отображение групповых баблов и стеков сообщений
|
- Полностью переработано поведение блока Requests: pull-жест, раскрытие и скрытие как у архива в Telegram
|
||||||
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются
|
- Доработана вытягивающаяся анимация: requests сразу появляются первым элементом при pull вниз
|
||||||
- Исправлена обрезка имени отправителя в медиа-баблах
|
- Убраны рывки и прыжки списка чатов при анимациях и при пустом списке запросов
|
||||||
- Исправлено растяжение фото в forwarded/media-пузырях
|
|
||||||
|
|
||||||
Интерфейс
|
Чаты и группы
|
||||||
- Убрана лишняя рамка вокруг аватарки в боковом меню
|
- Исправлены групповые баблы и аватарки в стеках сообщений, устранены кривые состояния в медиа-блоках
|
||||||
|
- Исправлена обрезка имени отправителя в групповых медиа-сообщениях
|
||||||
|
- Плашки даты в диалоге приведены к 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()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -20,6 +19,7 @@ data class FileDownloadState(
|
|||||||
enum class FileDownloadStatus {
|
enum class FileDownloadStatus {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
DOWNLOADING,
|
DOWNLOADING,
|
||||||
|
PAUSED,
|
||||||
DECRYPTING,
|
DECRYPTING,
|
||||||
DONE,
|
DONE,
|
||||||
ERROR
|
ERROR
|
||||||
@@ -35,6 +35,28 @@ object FileDownloadManager {
|
|||||||
|
|
||||||
/** Текущие Job'ы — чтобы не запускать повторно */
|
/** Текущие Job'ы — чтобы не запускать повторно */
|
||||||
private val jobs = mutableMapOf<String, 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 ───
|
// ─── helpers ───
|
||||||
|
|
||||||
@@ -67,9 +89,16 @@ object FileDownloadManager {
|
|||||||
*/
|
*/
|
||||||
fun isDownloading(attachmentId: String): Boolean {
|
fun isDownloading(attachmentId: String): Boolean {
|
||||||
val state = _downloads.value[attachmentId] ?: return false
|
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
|
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
||||||
*/
|
*/
|
||||||
@@ -81,7 +110,6 @@ object FileDownloadManager {
|
|||||||
* Скачивание продолжается даже если пользователь вышел из чата.
|
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||||
*/
|
*/
|
||||||
fun download(
|
fun download(
|
||||||
context: Context,
|
|
||||||
attachmentId: String,
|
attachmentId: String,
|
||||||
downloadTag: String,
|
downloadTag: String,
|
||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
@@ -90,132 +118,234 @@ object FileDownloadManager {
|
|||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File
|
savedFile: File
|
||||||
) {
|
) {
|
||||||
// Уже в процессе?
|
val request = DownloadRequest(
|
||||||
if (jobs[attachmentId]?.isActive == true) return
|
attachmentId = attachmentId,
|
||||||
val normalizedAccount = accountPublicKey.trim()
|
downloadTag = downloadTag,
|
||||||
val savedPath = savedFile.absolutePath
|
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 {
|
pauseRequested.add(attachmentId)
|
||||||
try {
|
val pausedProgress = current.progress.coerceIn(0f, 0.98f)
|
||||||
update(
|
update(
|
||||||
attachmentId,
|
id = attachmentId,
|
||||||
fileName,
|
fileName = current.fileName,
|
||||||
FileDownloadStatus.DOWNLOADING,
|
status = FileDownloadStatus.PAUSED,
|
||||||
0f,
|
progress = pausedProgress,
|
||||||
normalizedAccount,
|
accountPublicKey = current.accountPublicKey,
|
||||||
savedPath
|
savedPath = current.savedPath
|
||||||
)
|
)
|
||||||
|
|
||||||
// Запускаем polling прогресса из TransportManager
|
TransportManager.cancelDownload(attachmentId)
|
||||||
val progressJob = launch {
|
jobs[attachmentId]?.cancel()
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val success = withContext(Dispatchers.IO) {
|
fun resume(attachmentId: String) {
|
||||||
if (isGroupStoredKey(chachaKey)) {
|
val request = requests[attachmentId] ?: return
|
||||||
downloadGroupFile(
|
if (jobs[attachmentId]?.isActive == true) {
|
||||||
attachmentId = attachmentId,
|
resumeAfterPause.add(attachmentId)
|
||||||
downloadTag = downloadTag,
|
return
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
pauseRequested.remove(attachmentId)
|
||||||
|
startDownload(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отменяет скачивание
|
* Отменяет скачивание
|
||||||
*/
|
*/
|
||||||
fun cancel(attachmentId: String) {
|
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[attachmentId]?.cancel()
|
||||||
jobs.remove(attachmentId)
|
jobs.remove(attachmentId)
|
||||||
_downloads.update { it - 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) ───
|
// ─── internal download logic (moved from FileAttachment) ───
|
||||||
|
|
||||||
private suspend fun downloadGroupFile(
|
private suspend fun downloadGroupFile(
|
||||||
@@ -225,10 +355,21 @@ object FileDownloadManager {
|
|||||||
privateKey: String,
|
privateKey: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File,
|
savedFile: File,
|
||||||
|
encryptedPartFile: File,
|
||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
savedPath: String
|
savedPath: String
|
||||||
): Boolean {
|
): 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(
|
update(
|
||||||
attachmentId,
|
attachmentId,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -283,11 +424,18 @@ object FileDownloadManager {
|
|||||||
privateKey: String,
|
privateKey: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File,
|
savedFile: File,
|
||||||
|
encryptedPartFile: File,
|
||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
savedPath: String
|
savedPath: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Streaming: скачиваем во temp file
|
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
|
||||||
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
val tempFile =
|
||||||
|
TransportManager.downloadFileRawResumable(
|
||||||
|
id = attachmentId,
|
||||||
|
tag = downloadTag,
|
||||||
|
targetFile = encryptedPartFile,
|
||||||
|
resumeFromBytes = resumeBytes
|
||||||
|
)
|
||||||
update(
|
update(
|
||||||
attachmentId,
|
attachmentId,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -316,7 +464,7 @@ object FileDownloadManager {
|
|||||||
savedFile
|
savedFile
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
tempFile.delete()
|
encryptedPartFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
update(
|
update(
|
||||||
@@ -339,12 +487,19 @@ object FileDownloadManager {
|
|||||||
savedPath: String
|
savedPath: String
|
||||||
) {
|
) {
|
||||||
_downloads.update { map ->
|
_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 + (
|
map + (
|
||||||
id to FileDownloadState(
|
id to FileDownloadState(
|
||||||
attachmentId = id,
|
attachmentId = id,
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
status = status,
|
status = status,
|
||||||
progress = progress,
|
progress = normalizedProgress,
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
savedPath = savedPath
|
savedPath = savedPath
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Состояние загрузки/скачивания файла
|
* Состояние загрузки/скачивания файла
|
||||||
@@ -41,6 +47,7 @@ object TransportManager {
|
|||||||
|
|
||||||
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||||
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||||
|
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
.connectTimeout(60, TimeUnit.SECONDS)
|
||||||
@@ -93,6 +100,8 @@ object TransportManager {
|
|||||||
repeat(MAX_RETRIES) { attempt ->
|
repeat(MAX_RETRIES) { attempt ->
|
||||||
try {
|
try {
|
||||||
return block()
|
return block()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
||||||
if (attempt < MAX_RETRIES - 1) {
|
if (attempt < MAX_RETRIES - 1) {
|
||||||
@@ -110,6 +119,54 @@ object TransportManager {
|
|||||||
val packet = PacketRequestTransport()
|
val packet = PacketRequestTransport()
|
||||||
ProtocolManager.sendPacket(packet)
|
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()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = suspendCoroutine<Response> { cont ->
|
val response = awaitDownloadResponse(id, request)
|
||||||
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 (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw IOException("Download failed: ${response.code}")
|
throw IOException("Download failed: ${response.code}")
|
||||||
@@ -310,6 +357,7 @@ object TransportManager {
|
|||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
activeDownloadCalls.remove(id)?.cancel()
|
||||||
// Удаляем из списка скачиваний
|
// Удаляем из списка скачиваний
|
||||||
_downloading.value = _downloading.value.filter { it.id != id }
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
}
|
}
|
||||||
@@ -338,84 +386,129 @@ object TransportManager {
|
|||||||
* @return Временный файл с зашифрованным содержимым
|
* @return Временный файл с зашифрованным содержимым
|
||||||
*/
|
*/
|
||||||
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
|
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer()
|
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
|
||||||
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
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 {
|
try {
|
||||||
withRetry {
|
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")
|
.url("$server/d/$tag")
|
||||||
.get()
|
.get()
|
||||||
.build()
|
if (startOffset > 0L) {
|
||||||
|
requestBuilder.addHeader("Range", "bytes=$startOffset-")
|
||||||
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, requestBuilder.build())
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw IOException("Download failed: ${response.code}")
|
throw IOException("Download failed: ${response.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body ?: throw IOException("Empty response body")
|
val body = response.body ?: throw IOException("Empty response body")
|
||||||
val contentLength = body.contentLength()
|
val rangeAccepted = response.code == 206
|
||||||
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
|
val writeFromOffset = if (rangeAccepted) startOffset else 0L
|
||||||
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
|
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 {
|
if (writeFromOffset == 0L && targetFile.exists()) {
|
||||||
var totalRead = 0L
|
targetFile.delete()
|
||||||
val buffer = ByteArray(64 * 1024)
|
}
|
||||||
|
targetFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
body.byteStream().use { inputStream ->
|
val append = writeFromOffset > 0L
|
||||||
tempFile.outputStream().use { outputStream ->
|
var totalRead = writeFromOffset
|
||||||
while (true) {
|
val buffer = ByteArray(64 * 1024)
|
||||||
val bytesRead = inputStream.read(buffer)
|
|
||||||
if (bytesRead == -1) break
|
body.byteStream().use { inputStream ->
|
||||||
outputStream.write(buffer, 0, bytesRead)
|
java.io.FileOutputStream(targetFile, append).use { outputStream ->
|
||||||
totalRead += bytesRead
|
while (true) {
|
||||||
if (contentLength > 0) {
|
coroutineContext.ensureActive()
|
||||||
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
|
val bytesRead = try {
|
||||||
_downloading.value = _downloading.value.map {
|
inputStream.read(buffer)
|
||||||
if (it.id == id) it.copy(progress = progress) else it
|
} 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) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog(
|
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
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
activeDownloadCalls.remove(id)?.cancel()
|
||||||
_downloading.value = _downloading.value.filter { it.id != id }
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ package com.rosetta.messenger.ui.chats
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
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.net.Uri
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.View
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
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.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -101,7 +94,6 @@ import com.rosetta.messenger.network.SearchUser
|
|||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||||
import com.rosetta.messenger.ui.chats.components.*
|
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.InAppCameraScreen
|
||||||
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
|
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
|
||||||
import com.rosetta.messenger.ui.chats.input.*
|
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 val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
||||||
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
|
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 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 {
|
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
|
||||||
val firstCalendar =
|
val firstCalendar =
|
||||||
@@ -241,6 +234,8 @@ fun ChatDetailScreen(
|
|||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor
|
val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor
|
||||||
|
val dateHeaderBackgroundColor =
|
||||||
|
if (isDarkTheme || hasChatWallpaper) Color(0x80212121) else Color(0xCCE8E8ED)
|
||||||
val headerIconColor = Color.White
|
val headerIconColor = Color.White
|
||||||
|
|
||||||
// 🔥 Keyboard & Emoji Coordinator
|
// 🔥 Keyboard & Emoji Coordinator
|
||||||
@@ -353,13 +348,17 @@ fun ChatDetailScreen(
|
|||||||
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||||
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
|
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
// 🎨 Управление статус баром.
|
||||||
|
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
|
||||||
|
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
if (showImageViewer) {
|
if (showImageViewer) {
|
||||||
SystemBarsStyleUtils.applyFullscreenDark(window, view)
|
SystemBarsStyleUtils.applyFullscreenDark(window, view)
|
||||||
} else {
|
} else {
|
||||||
if (window != null && view != null) {
|
val isOverlayControllingSystemBars = showMediaPicker
|
||||||
|
|
||||||
|
if (!isOverlayControllingSystemBars && window != null && view != null) {
|
||||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
ic.isAppearanceLightStatusBars = false
|
ic.isAppearanceLightStatusBars = false
|
||||||
@@ -398,6 +397,7 @@ fun ChatDetailScreen(
|
|||||||
var pendingCameraPhotoUri by remember {
|
var pendingCameraPhotoUri by remember {
|
||||||
mutableStateOf<Uri?>(null)
|
mutableStateOf<Uri?>(null)
|
||||||
} // Фото для редактирования
|
} // Фото для редактирования
|
||||||
|
var pendingCameraPhotoCaption by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// 📷 Показать встроенную камеру (без системного превью)
|
// 📷 Показать встроенную камеру (без системного превью)
|
||||||
var showInAppCamera by remember { mutableStateOf(false) }
|
var showInAppCamera by remember { mutableStateOf(false) }
|
||||||
@@ -636,6 +636,15 @@ fun ChatDetailScreen(
|
|||||||
// If typing, the user is obviously online — never show "offline" while typing
|
// If typing, the user is obviously online — never show "offline" while typing
|
||||||
val isOnline = rawIsOnline || isTyping
|
val isOnline = rawIsOnline || isTyping
|
||||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
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
|
// <20>🔥 Reply/Forward state
|
||||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||||
@@ -1163,6 +1172,37 @@ fun ChatDetailScreen(
|
|||||||
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
|
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
|
||||||
derivedStateOf { messagesWithDates.isNotEmpty() && !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: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
||||||
// 🔥 Скроллим только если изменился ID самого нового сообщения
|
// 🔥 Скроллим только если изменился ID самого нового сообщения
|
||||||
@@ -2335,10 +2375,9 @@ fun ChatDetailScreen(
|
|||||||
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||||
// when content paddings (bottom bar/IME) change.
|
// when content paddings (bottom bar/IME) change.
|
||||||
if (chatWallpaperResId != null) {
|
if (chatWallpaperResId != null) {
|
||||||
TiledChatWallpaper(
|
ChatWallpaperBackground(
|
||||||
wallpaperResId = chatWallpaperResId,
|
wallpaperResId = chatWallpaperResId,
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = Modifier.matchParentSize()
|
||||||
tileScale = 0.9f
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
@@ -2365,7 +2404,7 @@ fun ChatDetailScreen(
|
|||||||
when {
|
when {
|
||||||
// 🔥 СКЕЛЕТОН - показываем пока загружаются
|
// 🔥 СКЕЛЕТОН - показываем пока загружаются
|
||||||
// сообщения
|
// сообщения
|
||||||
isLoading -> {
|
showMessageSkeleton -> {
|
||||||
MessageSkeletonList(
|
MessageSkeletonList(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isGroupChat = isGroupChat,
|
isGroupChat = isGroupChat,
|
||||||
@@ -2373,6 +2412,9 @@ fun ChatDetailScreen(
|
|||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
isLoading && messages.isEmpty() -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
// Пустое состояние (нет сообщений)
|
// Пустое состояние (нет сообщений)
|
||||||
messages.isEmpty() -> {
|
messages.isEmpty() -> {
|
||||||
Column(
|
Column(
|
||||||
@@ -2563,12 +2605,41 @@ fun ChatDetailScreen(
|
|||||||
isMessageBoundary(message, prevMessage)
|
isMessageBoundary(message, prevMessage)
|
||||||
val isGroupStart =
|
val isGroupStart =
|
||||||
isMessageBoundary(message, nextMessage)
|
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 =
|
val showIncomingGroupAvatar =
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
!message.isOutgoing &&
|
!message.isOutgoing &&
|
||||||
senderPublicKeyForMessage
|
senderPublicKeyForMessage
|
||||||
.isNotBlank() &&
|
.isNotBlank() &&
|
||||||
isGroupStart
|
((index ==
|
||||||
|
runHeadIndex &&
|
||||||
|
isHeadPhase &&
|
||||||
|
showTail) ||
|
||||||
|
(index ==
|
||||||
|
runTailIndex &&
|
||||||
|
isTailPhase &&
|
||||||
|
isGroupStart))
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (showDate
|
if (showDate
|
||||||
@@ -2579,8 +2650,10 @@ fun ChatDetailScreen(
|
|||||||
message.timestamp
|
message.timestamp
|
||||||
.time
|
.time
|
||||||
),
|
),
|
||||||
secondaryTextColor =
|
textColor =
|
||||||
dateHeaderTextColor
|
dateHeaderTextColor,
|
||||||
|
backgroundColor =
|
||||||
|
dateHeaderBackgroundColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val selectionKey =
|
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()) {
|
if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
|
||||||
val avatarInsetPx =
|
val avatarInsetPx =
|
||||||
with(density) {
|
with(density) {
|
||||||
@@ -3568,7 +3677,8 @@ fun ChatDetailScreen(
|
|||||||
InAppCameraScreen(
|
InAppCameraScreen(
|
||||||
onDismiss = { showInAppCamera = false },
|
onDismiss = { showInAppCamera = false },
|
||||||
onPhotoTaken = { photoUri ->
|
onPhotoTaken = { photoUri ->
|
||||||
// Сначала редактор (skipEnterAnimation=1f), потом убираем камеру
|
// После камеры открываем тот же fullscreen-редактор,
|
||||||
|
// что и для фото из галереи.
|
||||||
pendingCameraPhotoUri = photoUri
|
pendingCameraPhotoUri = photoUri
|
||||||
showInAppCamera = false
|
showInAppCamera = false
|
||||||
}
|
}
|
||||||
@@ -3577,26 +3687,25 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// 📷 Image Editor для фото с камеры (с caption как в Telegram)
|
// 📷 Image Editor для фото с камеры (с caption как в Telegram)
|
||||||
pendingCameraPhotoUri?.let { uri ->
|
pendingCameraPhotoUri?.let { uri ->
|
||||||
ImageEditorScreen(
|
SimpleFullscreenPhotoOverlay(
|
||||||
imageUri = uri,
|
imageUri = uri,
|
||||||
onDismiss = {
|
modifier = Modifier.fillMaxSize().zIndex(100f),
|
||||||
pendingCameraPhotoUri = null
|
showCaptionInput = true,
|
||||||
inputFocusTrigger++
|
caption = pendingCameraPhotoCaption,
|
||||||
},
|
onCaptionChange = { pendingCameraPhotoCaption = it },
|
||||||
onSave = { editedUri ->
|
isDarkTheme = isDarkTheme,
|
||||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
onSend = { editedUri, caption ->
|
||||||
viewModel.sendImageFromUri(editedUri, "")
|
|
||||||
showMediaPicker = false
|
|
||||||
},
|
|
||||||
onSaveWithCaption = { editedUri, caption ->
|
|
||||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
|
||||||
viewModel.sendImageFromUri(editedUri, caption)
|
viewModel.sendImageFromUri(editedUri, caption)
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
|
pendingCameraPhotoUri = null
|
||||||
|
pendingCameraPhotoCaption = ""
|
||||||
|
inputFocusTrigger++
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
onDismiss = {
|
||||||
showCaptionInput = true,
|
pendingCameraPhotoUri = null
|
||||||
recipientName = user.title,
|
pendingCameraPhotoCaption = ""
|
||||||
skipEnterAnimation = true // Из камеры — мгновенно, без fade
|
inputFocusTrigger++
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3676,60 +3785,14 @@ private fun GroupMembersSubtitleSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TiledChatWallpaper(
|
private fun ChatWallpaperBackground(
|
||||||
wallpaperResId: Int,
|
wallpaperResId: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier
|
||||||
tileScale: Float = 0.9f
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
Image(
|
||||||
val wallpaperDrawable =
|
painter = painterResource(id = wallpaperResId),
|
||||||
remember(wallpaperResId, tileScale, context) {
|
contentDescription = "Chat wallpaper",
|
||||||
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(
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } },
|
contentScale = ContentScale.Crop
|
||||||
update = { view -> view.background = wallpaperDrawable }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2169,15 +2169,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
||||||
fun deleteMessage(messageId: String) {
|
fun deleteMessage(messageId: String) {
|
||||||
|
val account = myPublicKey ?: return
|
||||||
|
val opponent = opponentKey ?: return
|
||||||
|
val dialogKey = getDialogKey(account, opponent)
|
||||||
|
|
||||||
// Удаляем из UI сразу на main
|
// Удаляем из 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 если был
|
// Удаляем из БД в IO + удаляем pin если был
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val account = myPublicKey ?: return@launch
|
|
||||||
val dialogKey = opponentKey ?: return@launch
|
|
||||||
pinnedMessageDao.removePin(account, dialogKey, messageId)
|
pinnedMessageDao.removePin(account, dialogKey, messageId)
|
||||||
messageDao.deleteMessage(account, 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val recipient = opponentKey
|
||||||
|
val sender = myPublicKey
|
||||||
|
val privateKey = myPrivateKey
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
|
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(
|
ProtocolManager.addLog(
|
||||||
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
|
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
|
||||||
)
|
)
|
||||||
|
|
||||||
backgroundUploadScope.launch {
|
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 =
|
val preparedImages =
|
||||||
imageUris.mapIndexedNotNull { index, uri ->
|
imageUris.mapIndexed { index, uri ->
|
||||||
val (width, height) =
|
val (width, height) =
|
||||||
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
|
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
|
||||||
context,
|
context,
|
||||||
@@ -3523,7 +3621,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ProtocolManager.addLog(
|
ProtocolManager.addLog(
|
||||||
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
|
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
|
||||||
)
|
)
|
||||||
return@mapIndexedNotNull null
|
throw IllegalStateException(
|
||||||
|
"group item#$index base64 conversion failed"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val blurhash =
|
val blurhash =
|
||||||
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
|
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
|
||||||
@@ -3533,26 +3633,156 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ProtocolManager.addLog(
|
ProtocolManager.addLog(
|
||||||
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
|
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
|
||||||
)
|
)
|
||||||
ImageData(
|
index to
|
||||||
base64 = imageBase64,
|
ImageData(
|
||||||
blurhash = blurhash,
|
base64 = imageBase64,
|
||||||
width = width,
|
blurhash = blurhash,
|
||||||
height = height
|
width = width,
|
||||||
)
|
height = height
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preparedImages.isEmpty()) {
|
if (preparedImages.isEmpty()) {
|
||||||
ProtocolManager.addLog(
|
ProtocolManager.addLog(
|
||||||
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
|
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
|
||||||
)
|
)
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
|
isSending = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
ProtocolManager.addLog(
|
ProtocolManager.addLog(
|
||||||
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
|
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
|
||||||
)
|
)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
try {
|
||||||
sendImageGroup(preparedImages, caption)
|
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 com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.math.hypot
|
import kotlin.math.hypot
|
||||||
@@ -108,6 +109,8 @@ private enum class DeviceResolveAction {
|
|||||||
DECLINE
|
DECLINE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||||
|
|
||||||
// Avatar colors matching React Native app (Mantine inspired)
|
// Avatar colors matching React Native app (Mantine inspired)
|
||||||
// Light theme colors (background lighter, text darker)
|
// Light theme colors (background lighter, text darker)
|
||||||
private val avatarColorsLight =
|
private val avatarColorsLight =
|
||||||
@@ -480,6 +483,9 @@ fun ChatsListScreen(
|
|||||||
it.status ==
|
it.status ==
|
||||||
com.rosetta.messenger.network.FileDownloadStatus
|
com.rosetta.messenger.network.FileDownloadStatus
|
||||||
.DOWNLOADING ||
|
.DOWNLOADING ||
|
||||||
|
it.status ==
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus
|
||||||
|
.PAUSED ||
|
||||||
it.status ==
|
it.status ==
|
||||||
com.rosetta.messenger.network.FileDownloadStatus
|
com.rosetta.messenger.network.FileDownloadStatus
|
||||||
.DECRYPTING
|
.DECRYPTING
|
||||||
@@ -1791,7 +1797,29 @@ fun ChatsListScreen(
|
|||||||
val requests = chatsState.requests
|
val requests = chatsState.requests
|
||||||
val requestsCount = chatsState.requestsCount
|
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(
|
AnimatedContent(
|
||||||
targetState = showDownloadsScreen,
|
targetState = showDownloadsScreen,
|
||||||
@@ -1999,6 +2027,8 @@ fun ChatsListScreen(
|
|||||||
} // Close Box wrapper
|
} // Close Box wrapper
|
||||||
} else if (showSkeleton) {
|
} else if (showSkeleton) {
|
||||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||||
|
} else if (isLoading && chatsState.isEmpty) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize())
|
||||||
} else if (chatsState.isEmpty) {
|
} else if (chatsState.isEmpty) {
|
||||||
EmptyChatsState(
|
EmptyChatsState(
|
||||||
isDarkTheme = isDarkTheme,
|
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,
|
// When a new device confirmation banner appears at the top,
|
||||||
// smoothly bring the list to top so the banner is visible.
|
// smoothly bring the list to top so the banner is visible.
|
||||||
LaunchedEffect(pendingDeviceVerification?.deviceId) {
|
LaunchedEffect(pendingDeviceVerification?.deviceId) {
|
||||||
@@ -2087,44 +2109,135 @@ fun ChatsListScreen(
|
|||||||
lastAutoScrolledVerificationId = verificationId
|
lastAutoScrolledVerificationId = verificationId
|
||||||
}
|
}
|
||||||
|
|
||||||
// NestedScroll — ловим направление свайпа даже без скролла
|
val requestsNestedScroll =
|
||||||
// Для появления: накапливаем pull down дельту, нужен сильный жест
|
remember(
|
||||||
val requestsNestedScroll = remember(hapticFeedback) {
|
requestsCount,
|
||||||
var accumulatedPullDown = 0f
|
chatListState,
|
||||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
requestsRevealThresholdPx,
|
||||||
override fun onPreScroll(
|
requestsHideThresholdPx,
|
||||||
available: androidx.compose.ui.geometry.Offset,
|
hapticFeedback
|
||||||
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
) {
|
||||||
): androidx.compose.ui.geometry.Offset {
|
var accumulatedPullDown = 0f
|
||||||
if (available.y < -10f) {
|
var accumulatedPullUp = 0f
|
||||||
// Свайп вверх — прячем легко
|
val pullDownLimit =
|
||||||
accumulatedPullDown = 0f
|
requestsRevealThresholdPx * 1.25f
|
||||||
isRequestsVisible = false
|
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||||
} else if (available.y > 0f && !isRequestsVisible) {
|
override fun onPreScroll(
|
||||||
// Свайп вниз — накапливаем для появления
|
available: androidx.compose.ui.geometry.Offset,
|
||||||
accumulatedPullDown += available.y
|
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
if (accumulatedPullDown > 120f) {
|
): androidx.compose.ui.geometry.Offset {
|
||||||
isRequestsVisible = true
|
if (source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag ||
|
||||||
|
requestsCount <= 0
|
||||||
|
) {
|
||||||
accumulatedPullDown = 0f
|
accumulatedPullDown = 0f
|
||||||
hapticFeedback.performHapticFeedback(
|
accumulatedPullUp = 0f
|
||||||
HapticFeedbackType.LongPress
|
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) {
|
if (available.y >= 0f) {
|
||||||
accumulatedPullDown = 0f
|
accumulatedPullUp = 0f
|
||||||
}
|
}
|
||||||
return androidx.compose.ui.geometry.Offset.Zero
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPostFling(
|
if (!isRequestsVisible && atTop) {
|
||||||
consumed: androidx.compose.ui.unit.Velocity,
|
if (available.y > 0f) {
|
||||||
available: androidx.compose.ui.unit.Velocity
|
accumulatedPullDown =
|
||||||
): androidx.compose.ui.unit.Velocity {
|
(accumulatedPullDown + available.y)
|
||||||
accumulatedPullDown = 0f
|
.coerceAtMost(
|
||||||
return androidx.compose.ui.unit.Velocity.Zero
|
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(
|
LazyColumn(
|
||||||
state = chatListState,
|
state = chatListState,
|
||||||
@@ -2163,102 +2276,96 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item(key = "requests_section") {
|
if (requestsCount > 0) {
|
||||||
val isRequestsSectionVisible =
|
item(key = "requests_section") {
|
||||||
requestsCount > 0 &&
|
val requestsSectionProgress by
|
||||||
isRequestsVisible
|
animateFloatAsState(
|
||||||
AnimatedVisibility(
|
targetValue =
|
||||||
visible =
|
if (isRequestsVisible) 1f
|
||||||
isRequestsSectionVisible,
|
else requestsPullProgress,
|
||||||
enter =
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = {
|
|
||||||
fullHeight ->
|
|
||||||
-fullHeight /
|
|
||||||
3
|
|
||||||
},
|
|
||||||
animationSpec =
|
animationSpec =
|
||||||
tween(
|
spring(
|
||||||
durationMillis =
|
dampingRatio =
|
||||||
260,
|
Spring.DampingRatioNoBouncy,
|
||||||
easing =
|
stiffness =
|
||||||
FastOutSlowInEasing
|
Spring.StiffnessMediumLow
|
||||||
)
|
),
|
||||||
) +
|
label =
|
||||||
expandVertically(
|
"requestsSectionProgress"
|
||||||
expandFrom =
|
)
|
||||||
Alignment
|
val clampedProgress =
|
||||||
.Top,
|
requestsSectionProgress
|
||||||
animationSpec =
|
.coerceIn(
|
||||||
tween(
|
0f,
|
||||||
durationMillis =
|
1.15f
|
||||||
260,
|
)
|
||||||
easing =
|
val revealProgress =
|
||||||
FastOutSlowInEasing
|
FastOutSlowInEasing
|
||||||
)
|
.transform(
|
||||||
) +
|
clampedProgress
|
||||||
fadeIn(
|
.coerceIn(
|
||||||
animationSpec =
|
0f,
|
||||||
tween(
|
1f
|
||||||
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
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) {
|
val stretchOvershoot =
|
||||||
Column {
|
(clampedProgress - 1f)
|
||||||
RequestsSection(
|
.coerceAtLeast(
|
||||||
count =
|
0f
|
||||||
requestsCount,
|
)
|
||||||
requests =
|
val sectionHeight =
|
||||||
requests,
|
76.dp *
|
||||||
isDarkTheme =
|
revealProgress +
|
||||||
isDarkTheme,
|
10.dp *
|
||||||
onClick = {
|
stretchOvershoot
|
||||||
openRequestsRouteSafely()
|
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(
|
||||||
Divider(
|
color =
|
||||||
color =
|
dividerColor,
|
||||||
dividerColor,
|
thickness =
|
||||||
thickness =
|
0.5.dp
|
||||||
0.5.dp
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2332,16 +2439,7 @@ fun ChatsListScreen(
|
|||||||
listBackgroundColor
|
listBackgroundColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column {
|
||||||
modifier =
|
|
||||||
Modifier.animateItemPlacement(
|
|
||||||
animationSpec =
|
|
||||||
tween(
|
|
||||||
durationMillis = 200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
SwipeableDialogItem(
|
SwipeableDialogItem(
|
||||||
dialog =
|
dialog =
|
||||||
dialog,
|
dialog,
|
||||||
@@ -4383,17 +4481,17 @@ fun DialogItemContent(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(22.dp)
|
Modifier.size(16.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFFE53935)),
|
.background(Color(0xFFE53935)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "!",
|
text = "!",
|
||||||
fontSize = 13.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
lineHeight = 13.sp,
|
lineHeight = 10.sp,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4962,6 +5060,7 @@ private fun formatDownloadStatusText(
|
|||||||
return when (item.status) {
|
return when (item.status) {
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
|
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.DECRYPTING -> "Decrypting"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
|
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"
|
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.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
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.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
|
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
|
||||||
@@ -339,6 +340,7 @@ fun GroupInfoScreen(
|
|||||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||||
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||||
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2)
|
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2)
|
||||||
|
val groupMenuTrailingIconSize = 22.dp
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
@@ -805,8 +807,8 @@ fun GroupInfoScreen(
|
|||||||
swipedMemberKey = null
|
swipedMemberKey = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(swipedMemberKey) {
|
LaunchedEffect(swipedMemberKey, showEncryptionPage) {
|
||||||
onSwipeBackEnabledChanged(swipedMemberKey == null)
|
onSwipeBackEnabledChanged(swipedMemberKey == null && !showEncryptionPage)
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -1207,7 +1209,7 @@ fun GroupInfoScreen(
|
|||||||
imageVector = Icons.Default.PersonAdd,
|
imageVector = Icons.Default.PersonAdd,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = accentColor,
|
tint = accentColor,
|
||||||
modifier = Modifier.size(22.dp)
|
modifier = Modifier.size(groupMenuTrailingIconSize)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,7 +1235,7 @@ fun GroupInfoScreen(
|
|||||||
)
|
)
|
||||||
if (encryptionKeyLoading) {
|
if (encryptionKeyLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(groupMenuTrailingIconSize),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = accentColor
|
color = accentColor
|
||||||
)
|
)
|
||||||
@@ -1241,12 +1243,12 @@ fun GroupInfoScreen(
|
|||||||
val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
|
val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(34.dp)
|
.size(groupMenuTrailingIconSize)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
) {
|
) {
|
||||||
TelegramStyleIdenticon(
|
TelegramStyleIdenticon(
|
||||||
keyRender = identiconKey,
|
keyRender = identiconKey,
|
||||||
size = 34.dp,
|
size = groupMenuTrailingIconSize,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1565,11 +1567,12 @@ fun GroupInfoScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
SwipeBackContainer(
|
||||||
visible = showEncryptionPage,
|
isVisible = showEncryptionPage,
|
||||||
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
|
onBack = { showEncryptionPage = false },
|
||||||
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
|
isDarkTheme = isDarkTheme,
|
||||||
modifier = Modifier.fillMaxSize()
|
layer = 3,
|
||||||
|
propagateBackgroundProgress = false
|
||||||
) {
|
) {
|
||||||
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
||||||
GroupEncryptionKeyPage(
|
GroupEncryptionKeyPage(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ internal fun MediaGrid(
|
|||||||
mediaItems: List<MediaItem>,
|
mediaItems: List<MediaItem>,
|
||||||
selectedItemOrder: List<Long>,
|
selectedItemOrder: List<Long>,
|
||||||
showCameraItem: Boolean = true,
|
showCameraItem: Boolean = true,
|
||||||
|
cameraEnabled: Boolean = true,
|
||||||
gridState: LazyGridState = rememberLazyGridState(),
|
gridState: LazyGridState = rememberLazyGridState(),
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||||
@@ -87,6 +88,7 @@ internal fun MediaGrid(
|
|||||||
item(key = "camera_button") {
|
item(key = "camera_button") {
|
||||||
CameraGridItem(
|
CameraGridItem(
|
||||||
onClick = onCameraClick,
|
onClick = onCameraClick,
|
||||||
|
enabled = cameraEnabled,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,7 @@ internal fun MediaGrid(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun CameraGridItem(
|
internal fun CameraGridItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -147,7 +150,9 @@ internal fun CameraGridItem(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
val enabledState = rememberUpdatedState(enabled)
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val provider = cameraProvider
|
val provider = cameraProvider
|
||||||
val preview = previewUseCase
|
val preview = previewUseCase
|
||||||
@@ -167,10 +172,10 @@ internal fun CameraGridItem(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = onClick),
|
.clickable(enabled = enabled, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (hasCameraPermission) {
|
if (hasCameraPermission && enabled) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
val previewView = PreviewView(ctx).apply {
|
val previewView = PreviewView(ctx).apply {
|
||||||
@@ -181,6 +186,9 @@ internal fun CameraGridItem(
|
|||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
|
if (!enabledState.value) {
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
val provider = cameraProviderFuture.get()
|
val provider = cameraProviderFuture.get()
|
||||||
cameraProvider = provider
|
cameraProvider = provider
|
||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
|
|||||||
internal fun AttachAlertPhotoLayout(
|
internal fun AttachAlertPhotoLayout(
|
||||||
state: AttachAlertUiState,
|
state: AttachAlertUiState,
|
||||||
gridState: LazyGridState,
|
gridState: LazyGridState,
|
||||||
|
cameraEnabled: Boolean = true,
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||||
onItemCheckClick: (MediaItem) -> Unit,
|
onItemCheckClick: (MediaItem) -> Unit,
|
||||||
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
|
|||||||
mediaItems = state.visibleMediaItems,
|
mediaItems = state.visibleMediaItems,
|
||||||
selectedItemOrder = state.selectedItemOrder,
|
selectedItemOrder = state.selectedItemOrder,
|
||||||
showCameraItem = state.visibleAlbum?.isAllMedia != false,
|
showCameraItem = state.visibleAlbum?.isAllMedia != false,
|
||||||
|
cameraEnabled = cameraEnabled,
|
||||||
gridState = gridState,
|
gridState = gridState,
|
||||||
onCameraClick = onCameraClick,
|
onCameraClick = onCameraClick,
|
||||||
onItemClick = onItemClick,
|
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).
|
* 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
|
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
||||||
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
||||||
snapshotFlow {
|
val dark = isDarkTheme
|
||||||
PickerSystemBarsSnapshot(
|
val fullScreen = isPickerFullScreen
|
||||||
scrimAlpha = scrimAlpha,
|
|
||||||
isFullScreen = isPickerFullScreen,
|
if (hasNativeNavigationBar) {
|
||||||
isDarkTheme = isDarkTheme,
|
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
window.navigationBarColor = navBaseColor
|
||||||
)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
}.collect { state ->
|
window.isNavigationBarContrastEnforced = true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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(
|
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
|
||||||
state = state,
|
state = state,
|
||||||
gridState = mediaGridState,
|
gridState = mediaGridState,
|
||||||
|
cameraEnabled = !isClosing,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
requestClose {
|
requestClose {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
|
|||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.boundsInWindow
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -333,6 +341,105 @@ enum class DownloadStatus {
|
|||||||
ERROR
|
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 группируются в
|
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
|
||||||
* коллаж (как в Telegram)
|
* коллаж (как в Telegram)
|
||||||
@@ -1454,6 +1561,8 @@ fun FileAttachment(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||||
var downloadProgress by remember { mutableStateOf(0f) }
|
var downloadProgress by remember { mutableStateOf(0f) }
|
||||||
|
var isPaused by remember { mutableStateOf(false) }
|
||||||
|
var lastActionAtMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
// Bounce animation for icon
|
// Bounce animation for icon
|
||||||
val iconScale = remember { Animatable(0f) }
|
val iconScale = remember { Animatable(0f) }
|
||||||
@@ -1495,16 +1604,40 @@ fun FileAttachment(
|
|||||||
downloadStatus = when (state.status) {
|
downloadStatus = when (state.status) {
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
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.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||||
}
|
}
|
||||||
|
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(attachment.id) {
|
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)) {
|
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
isPaused = false
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
downloadStatus = if (isDownloadTag(preview)) {
|
downloadStatus = if (isDownloadTag(preview)) {
|
||||||
@@ -1516,6 +1649,7 @@ fun FileAttachment(
|
|||||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
||||||
}
|
}
|
||||||
|
isPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Открыть файл через системное приложение
|
// Открыть файл через системное приложение
|
||||||
@@ -1551,10 +1685,8 @@ fun FileAttachment(
|
|||||||
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||||
val download: () -> Unit = {
|
val download: () -> Unit = {
|
||||||
if (downloadTag.isNotEmpty()) {
|
if (downloadTag.isNotEmpty()) {
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
isPaused = false
|
||||||
downloadProgress = 0f
|
|
||||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||||
context = context,
|
|
||||||
attachmentId = attachment.id,
|
attachmentId = attachment.id,
|
||||||
downloadTag = downloadTag,
|
downloadTag = downloadTag,
|
||||||
chachaKey = chachaKey,
|
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: без внутреннего фона, просто иконка + текст
|
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.clickable(
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
enabled =
|
enabled =
|
||||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
!isSendingUpload &&
|
||||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
(downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||||
downloadStatus == DownloadStatus.ERROR
|
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||||
|
downloadStatus == DownloadStatus.ERROR ||
|
||||||
|
isDownloadInProgress ||
|
||||||
|
isPaused)
|
||||||
) {
|
) {
|
||||||
when (downloadStatus) {
|
val now = SystemClock.elapsedRealtime()
|
||||||
DownloadStatus.DOWNLOADED -> openFile()
|
if (now - lastActionAtMs < 220L) return@clickable
|
||||||
|
lastActionAtMs = now
|
||||||
|
|
||||||
|
when {
|
||||||
|
isPaused -> resumeDownload()
|
||||||
|
downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||||
|
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
|
||||||
|
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
|
||||||
else -> download()
|
else -> download()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1595,62 +1764,12 @@ fun FileAttachment(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Круглый фон иконки
|
TelegramFileActionButton(
|
||||||
Box(
|
state = actionState,
|
||||||
modifier =
|
progress = actionProgress,
|
||||||
Modifier.fillMaxSize()
|
indeterminate = isSendingUpload,
|
||||||
.clip(CircleShape)
|
modifier = Modifier.fillMaxSize()
|
||||||
.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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
@@ -1679,34 +1798,52 @@ fun FileAttachment(
|
|||||||
PrimaryBlue
|
PrimaryBlue
|
||||||
}
|
}
|
||||||
|
|
||||||
when (downloadStatus) {
|
if (isSendingUpload) {
|
||||||
DownloadStatus.DOWNLOADING -> {
|
AnimatedDotsText(
|
||||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
baseText = "Uploading",
|
||||||
// CDN download maps to progress 0..0.8
|
color = statusColor,
|
||||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
fontSize = 12.sp
|
||||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
)
|
||||||
Text(
|
} else {
|
||||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
when (downloadStatus) {
|
||||||
fontSize = 12.sp,
|
DownloadStatus.DOWNLOADING -> {
|
||||||
color = statusColor
|
if (isPaused) {
|
||||||
)
|
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||||
}
|
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||||
DownloadStatus.DECRYPTING -> {
|
Text(
|
||||||
AnimatedDotsText(
|
text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||||
baseText = "Decrypting",
|
fontSize = 12.sp,
|
||||||
color = statusColor,
|
color = statusColor
|
||||||
fontSize = 12.sp
|
)
|
||||||
)
|
} else {
|
||||||
}
|
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||||
else -> {
|
// CDN download maps to progress 0..0.8
|
||||||
Text(
|
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||||
text = when (downloadStatus) {
|
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||||
DownloadStatus.ERROR -> "File expired"
|
Text(
|
||||||
else -> "${formatFileSize(fileSize)} $fileExtension"
|
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||||
},
|
fontSize = 12.sp,
|
||||||
fontSize = 12.sp,
|
color = statusColor
|
||||||
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.PopupProperties
|
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)
|
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
|
@Composable
|
||||||
fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
fun DateHeader(
|
||||||
var isVisible by remember { mutableStateOf(false) }
|
dateText: String,
|
||||||
val alpha by
|
textColor: Color,
|
||||||
animateFloatAsState(
|
backgroundColor: Color,
|
||||||
targetValue = if (isVisible) 1f else 0f,
|
modifier: Modifier = Modifier,
|
||||||
animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
|
verticalPadding: Dp = 12.dp
|
||||||
label = "dateAlpha"
|
) {
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(dateText) { isVisible = true }
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer {
|
modifier
|
||||||
this.alpha = alpha
|
.fillMaxWidth()
|
||||||
},
|
.padding(vertical = verticalPadding),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = dateText,
|
text = dateText,
|
||||||
fontSize = 13.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = secondaryTextColor,
|
color = textColor,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.background(
|
Modifier.background(
|
||||||
color = secondaryTextColor.copy(alpha = 0.1f),
|
color = backgroundColor,
|
||||||
shape = RoundedCornerShape(12.dp)
|
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
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
val senderLabelText =
|
||||||
|
senderName
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.trim()
|
||||||
|
val senderLabelMaxWidth =
|
||||||
|
if (isGroupSenderAdmin) 170.dp else 220.dp
|
||||||
Text(
|
Text(
|
||||||
text = senderName,
|
text = senderLabelText,
|
||||||
color =
|
color =
|
||||||
groupSenderLabelColor(
|
groupSenderLabelColor(
|
||||||
senderPublicKey,
|
senderPublicKey,
|
||||||
@@ -870,6 +873,7 @@ fun MessageBubble(
|
|||||||
),
|
),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,13 +82,6 @@ import kotlin.math.roundToInt
|
|||||||
private const val TAG = "MediaPickerBottomSheet"
|
private const val TAG = "MediaPickerBottomSheet"
|
||||||
private const val ALL_MEDIA_ALBUM_ID = 0L
|
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
|
* Media item from gallery
|
||||||
*/
|
*/
|
||||||
@@ -606,56 +599,50 @@ fun MediaPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive updates — single snapshotFlow drives ALL system bar colors
|
// Telegram-style: system bar updates only by picker state,
|
||||||
LaunchedEffect(shouldShow, editingItem) {
|
// no per-frame status bar color animation.
|
||||||
|
LaunchedEffect(
|
||||||
|
shouldShow,
|
||||||
|
editingItem,
|
||||||
|
isPickerFullScreen,
|
||||||
|
isDarkTheme,
|
||||||
|
hasNativeNavigationBar
|
||||||
|
) {
|
||||||
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
||||||
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
||||||
|
val dark = isDarkTheme
|
||||||
|
val fullScreen = isPickerFullScreen
|
||||||
|
|
||||||
snapshotFlow {
|
if (hasNativeNavigationBar) {
|
||||||
PickerSystemBarsSnapshot(
|
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
scrimAlpha = scrimAlpha,
|
window.navigationBarColor = navBaseColor
|
||||||
isFullScreen = isPickerFullScreen,
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
isDarkTheme = isDarkTheme,
|
window.isNavigationBarContrastEnforced = true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 для показа поверх клавиатуры
|
// Используем Popup для показа поверх клавиатуры
|
||||||
@@ -1047,6 +1034,7 @@ fun MediaPickerBottomSheet(
|
|||||||
mediaItems = visibleMediaItems,
|
mediaItems = visibleMediaItems,
|
||||||
selectedItemOrder = selectedItemOrder,
|
selectedItemOrder = selectedItemOrder,
|
||||||
showCameraItem = selectedAlbum?.isAllMedia != false,
|
showCameraItem = selectedAlbum?.isAllMedia != false,
|
||||||
|
cameraEnabled = !isClosing,
|
||||||
gridState = mediaGridState,
|
gridState = mediaGridState,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
requestClose {
|
requestClose {
|
||||||
@@ -1659,6 +1647,7 @@ private fun MediaGrid(
|
|||||||
mediaItems: List<MediaItem>,
|
mediaItems: List<MediaItem>,
|
||||||
selectedItemOrder: List<Long>,
|
selectedItemOrder: List<Long>,
|
||||||
showCameraItem: Boolean = true,
|
showCameraItem: Boolean = true,
|
||||||
|
cameraEnabled: Boolean = true,
|
||||||
gridState: LazyGridState = rememberLazyGridState(),
|
gridState: LazyGridState = rememberLazyGridState(),
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||||
@@ -1685,6 +1674,7 @@ private fun MediaGrid(
|
|||||||
item(key = "camera_button") {
|
item(key = "camera_button") {
|
||||||
CameraGridItem(
|
CameraGridItem(
|
||||||
onClick = onCameraClick,
|
onClick = onCameraClick,
|
||||||
|
enabled = cameraEnabled,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1715,6 +1705,7 @@ private fun MediaGrid(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CameraGridItem(
|
private fun CameraGridItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -1753,7 +1744,9 @@ private fun CameraGridItem(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
val enabledState = rememberUpdatedState(enabled)
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val provider = cameraProvider
|
val provider = cameraProvider
|
||||||
val preview = previewUseCase
|
val preview = previewUseCase
|
||||||
@@ -1773,10 +1766,10 @@ private fun CameraGridItem(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = onClick),
|
.clickable(enabled = enabled, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (hasCameraPermission) {
|
if (hasCameraPermission && enabled) {
|
||||||
// Show live camera preview
|
// Show live camera preview
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
@@ -1788,6 +1781,9 @@ private fun CameraGridItem(
|
|||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
|
if (!enabledState.value) {
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
val provider = cameraProviderFuture.get()
|
val provider = cameraProviderFuture.get()
|
||||||
cameraProvider = provider
|
cameraProvider = provider
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.rosetta.messenger.ui.components
|
package com.rosetta.messenger.ui.components
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.animation.DecelerateInterpolator
|
import android.view.animation.DecelerateInterpolator
|
||||||
import android.view.inputmethod.InputMethodManager
|
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.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||||
@@ -174,6 +177,15 @@ fun SwipeBackContainer(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val focusManager = LocalFocusManager.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
|
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
|
||||||
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
||||||
@@ -333,6 +345,7 @@ fun SwipeBackContainer(
|
|||||||
var totalDragX = 0f
|
var totalDragX = 0f
|
||||||
var totalDragY = 0f
|
var totalDragY = 0f
|
||||||
var passedSlop = false
|
var passedSlop = false
|
||||||
|
var keyboardHiddenForGesture = false
|
||||||
|
|
||||||
// deferToChildren=true: pre-slop uses Main pass so children
|
// deferToChildren=true: pre-slop uses Main pass so children
|
||||||
// (e.g. LazyRow) process first — if they consume, we back off.
|
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||||
@@ -359,6 +372,14 @@ fun SwipeBackContainer(
|
|||||||
totalDragX += dragDelta.x
|
totalDragX += dragDelta.x
|
||||||
totalDragY += dragDelta.y
|
totalDragY += dragDelta.y
|
||||||
|
|
||||||
|
if (!keyboardHiddenForGesture &&
|
||||||
|
totalDragX > 10f &&
|
||||||
|
kotlin.math.abs(totalDragX) >
|
||||||
|
kotlin.math.abs(totalDragY)) {
|
||||||
|
dismissKeyboard()
|
||||||
|
keyboardHiddenForGesture = true
|
||||||
|
}
|
||||||
|
|
||||||
if (!passedSlop) {
|
if (!passedSlop) {
|
||||||
// Child (e.g. LazyRow) already consumed — let it handle
|
// Child (e.g. LazyRow) already consumed — let it handle
|
||||||
if (change.isConsumed) break
|
if (change.isConsumed) break
|
||||||
@@ -393,17 +414,8 @@ fun SwipeBackContainer(
|
|||||||
screenWidthPx,
|
screenWidthPx,
|
||||||
active = true
|
active = true
|
||||||
)
|
)
|
||||||
|
dismissKeyboard()
|
||||||
val imm =
|
keyboardHiddenForGesture = true
|
||||||
context.getSystemService(
|
|
||||||
Context.INPUT_METHOD_SERVICE
|
|
||||||
) as
|
|
||||||
InputMethodManager
|
|
||||||
imm.hideSoftInputFromWindow(
|
|
||||||
view.windowToken,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
focusManager.clearFocus()
|
|
||||||
|
|
||||||
change.consume()
|
change.consume()
|
||||||
} else {
|
} else {
|
||||||
@@ -489,6 +501,7 @@ fun SwipeBackContainer(
|
|||||||
shouldShow = false
|
shouldShow = false
|
||||||
dragOffset = 0f
|
dragOffset = 0f
|
||||||
clearSharedSwipeProgressIfOwner()
|
clearSharedSwipeProgressIfOwner()
|
||||||
|
dismissKeyboard()
|
||||||
onBack()
|
onBack()
|
||||||
} else {
|
} else {
|
||||||
offsetAnimatable.animateTo(
|
offsetAnimatable.animateTo(
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import compose.icons.TablerIcons
|
|||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -199,19 +200,12 @@ fun OtherProfileScreen(
|
|||||||
var avatarViewerTimestamp by remember { mutableStateOf(0L) }
|
var avatarViewerTimestamp by remember { mutableStateOf(0L) }
|
||||||
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
||||||
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
|
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
|
||||||
|
var tabSwitchJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = 0,
|
initialPage = 0,
|
||||||
pageCount = { OtherProfileTab.entries.size }
|
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
|
// Pager swipe → update tab + control swipe-back
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||||
@@ -826,7 +820,15 @@ fun OtherProfileScreen(
|
|||||||
) {
|
) {
|
||||||
OtherProfileSharedTabs(
|
OtherProfileSharedTabs(
|
||||||
selectedTab = selectedTab,
|
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
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ fun ThemeScreen(
|
|||||||
WallpaperSelectorRow(
|
WallpaperSelectorRow(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
selectedWallpaperId = wallpaperId,
|
selectedWallpaperId = wallpaperId,
|
||||||
|
wallpapers = ThemeWallpapers.forTheme(isDarkTheme),
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
secondaryTextColor = secondaryTextColor,
|
secondaryTextColor = secondaryTextColor,
|
||||||
onWallpaperSelected = { selectedId ->
|
onWallpaperSelected = { selectedId ->
|
||||||
@@ -292,7 +293,12 @@ fun ThemeScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
TelegramInfoText(
|
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
|
secondaryTextColor = secondaryTextColor
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -454,6 +460,7 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
|
|||||||
private fun WallpaperSelectorRow(
|
private fun WallpaperSelectorRow(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
selectedWallpaperId: String,
|
selectedWallpaperId: String,
|
||||||
|
wallpapers: List<ThemeWallpaper>,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
secondaryTextColor: Color,
|
secondaryTextColor: Color,
|
||||||
onWallpaperSelected: (String) -> Unit
|
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(
|
WallpaperSelectorItem(
|
||||||
title = wallpaper.name,
|
title = wallpaper.name,
|
||||||
wallpaperResId = wallpaper.drawableRes,
|
wallpaperResId = wallpaper.drawableRes,
|
||||||
|
|||||||
@@ -6,28 +6,94 @@ import com.rosetta.messenger.R
|
|||||||
data class ThemeWallpaper(
|
data class ThemeWallpaper(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val preferredTheme: WallpaperTheme,
|
||||||
@DrawableRes val drawableRes: Int
|
@DrawableRes val drawableRes: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class WallpaperTheme {
|
||||||
|
DARK,
|
||||||
|
LIGHT
|
||||||
|
}
|
||||||
|
|
||||||
object ThemeWallpapers {
|
object ThemeWallpapers {
|
||||||
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
|
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
|
||||||
val all: List<ThemeWallpaper> =
|
val all: List<ThemeWallpaper> =
|
||||||
listOf(
|
listOf(
|
||||||
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3),
|
ThemeWallpaper(
|
||||||
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4),
|
id = "back_3",
|
||||||
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5),
|
name = "Wallpaper 1",
|
||||||
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6),
|
preferredTheme = WallpaperTheme.DARK,
|
||||||
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7),
|
drawableRes = R.drawable.wallpaper_back_3
|
||||||
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(
|
||||||
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10),
|
id = "back_4",
|
||||||
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11),
|
name = "Wallpaper 2",
|
||||||
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1),
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2)
|
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 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
|
@DrawableRes
|
||||||
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user