Compare commits

...

17 Commits

Author SHA1 Message Date
9afbbae5c9 v1.2.4: реальная пауза скачивания с resume по Range
All checks were successful
Android Kernel Build / build (push) Successful in 50m9s
2026-03-20 14:48:17 +05:00
4440016d5f v1.2.4: фиксы медиапикера, файловых загрузок и UI групп 2026-03-20 14:29:12 +05:00
0353f845a5 Фикс скелетона и залипания вкладок в профиле 2026-03-20 12:26:33 +05:00
004b54ec7c Релиз 1.2.4: фиксы чатов, медиа и release notes 2026-03-20 00:44:18 +05:00
5ecb2a8db4 Универсальные обои для всех разрешений 2026-03-19 23:35:28 +05:00
f34e520d03 Починил optimistic и сохранение групповых фото при отправке 2026-03-19 22:34:00 +05:00
1ba173be54 Довел pull-анимацию реквестов: моментальный показ первым элементом 2026-03-19 22:22:01 +05:00
d41674ff78 Сделал плавную вытягивающуюся анимацию реквестов в чат-листе 2026-03-19 20:00:02 +05:00
bd6e033ed3 Исправил скрытие реквестов в чат-листе как у архива Telegram 2026-03-19 19:53:07 +05:00
72a2cf1b70 Переделал механику реквестов: отдельный pull-gesture и ручка раскрытия 2026-03-19 19:41:59 +05:00
2cf64e80eb Сделал мгновенное раскрытие реквестов при pull вниз 2026-03-19 19:28:05 +05:00
2602084764 Починил повторное появление реквестов при прокрутке в чат-листе 2026-03-19 19:22:23 +05:00
420ea6e560 Убрал рывки анимации блока реквестов в списке чатов 2026-03-19 19:09:09 +05:00
53946e2e6e Сделал стабильное появление реквестов при оттягивании списка 2026-03-19 16:50:13 +05:00
4d4130fefd Исправил прыжки списка чатов при пустых запросах 2026-03-19 16:35:41 +05:00
09df7586e7 Разделил обои на наборы для темной и светлой темы 2026-03-19 16:28:18 +05:00
13b61cf720 Сделал скрытие клавиатуры на back-свайпе во всех экранах 2026-03-19 16:19:06 +05:00
18 changed files with 1606 additions and 703 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.2.3"
val rosettaVersionCode = 25 // Increment on each release
val rosettaVersionName = "1.2.4"
val rosettaVersionCode = 26 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -19,14 +19,48 @@ object ReleaseNotes {
Что обновлено после версии 1.2.3
Группы и медиа
- Исправлено отображение групповых баблов и стеков сообщений
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются
- Исправлена обрезка имени отправителя в медиа-баблах
- Исправлено растяжение фото в forwarded/media-пузырях
Чат-лист и Requests
- Полностью переработано поведение блока Requests: pull-жест, раскрытие и скрытие как у архива в Telegram
- Доработана вытягивающаяся анимация: requests сразу появляются первым элементом при pull вниз
- Убраны рывки и прыжки списка чатов при анимациях и при пустом списке запросов
Интерфейс
- Убрана лишняя рамка вокруг аватарки в боковом меню
Чаты и группы
- Исправлены групповые баблы и аватарки в стеках сообщений, устранены кривые состояния в медиа-блоках
- Исправлена обрезка имени отправителя в групповых медиа-сообщениях
- Плашки даты в диалоге приведены к Telegram-стилю, добавлена плавающая верхняя дата при скролле
- Сообщение «you joined the group» теперь белого цвета в тёмной теме и на обоях
Медиа и локальные данные
- Исправлена отправка нескольких фото: добавлен корректный optimistic UI и стабильное отображение до/после перезахода
- Экран редактирования фото после камеры унифицирован с редактором фото из галереи
- Удалённые сообщения теперь корректно удаляются локально и не возвращаются после открытия диалога
Обои и темы
- Разделены наборы обоев для светлой и тёмной темы
- Исправлено поведение обоев на разных разрешениях: убраны повторения/растяжения, фон отображается стабильнее
Навигация и UI
- Back-свайп теперь везде скрывает клавиатуру (как на экране поиска)
- На экране группы выровнены размеры иконок Encryption Key и Add Members
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
- Приведён к нормальному размер индикатор ошибки в чат-листе
Медиапикер и камера
- Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры
- Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения
- Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss
Файлы и загрузки
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
- Обновлён экран активных загрузок: добавлен статус Paused
Групповые сообщения
- Добавлен truncate для длинных имён отправителей в групповых пузырьках
- Убраны переносы в имени отправителя в шапке группового сообщения
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -1,6 +1,5 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import kotlinx.coroutines.*
@@ -20,6 +19,7 @@ data class FileDownloadState(
enum class FileDownloadStatus {
QUEUED,
DOWNLOADING,
PAUSED,
DECRYPTING,
DONE,
ERROR
@@ -35,6 +35,28 @@ object FileDownloadManager {
/** Текущие Job'ы — чтобы не запускать повторно */
private val jobs = mutableMapOf<String, Job>()
/** Последние параметры скачивания — нужны для resume */
private val requests = mutableMapOf<String, DownloadRequest>()
/** Флаг, что cancel произошёл именно как user pause */
private val pauseRequested = mutableSetOf<String>()
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
private val resumeAfterPause = mutableSetOf<String>()
private data class DownloadRequest(
val attachmentId: String,
val downloadTag: String,
val chachaKey: String,
val privateKey: String,
val accountPublicKey: String,
val fileName: String,
val savedFile: File
)
private fun encryptedPartFile(request: DownloadRequest): File {
val parent = request.savedFile.parentFile ?: request.savedFile.absoluteFile.parentFile
val safeId = request.attachmentId.take(32).replace(Regex("[^A-Za-z0-9._-]"), "_")
return File(parent, ".dl_${safeId}.part")
}
// ─── helpers ───
@@ -67,9 +89,16 @@ object FileDownloadManager {
*/
fun isDownloading(attachmentId: String): Boolean {
val state = _downloads.value[attachmentId] ?: return false
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
return state.status == FileDownloadStatus.QUEUED ||
state.status == FileDownloadStatus.DOWNLOADING ||
state.status == FileDownloadStatus.DECRYPTING
}
fun isPaused(attachmentId: String): Boolean =
_downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED
fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId]
/**
* Возвращает Flow<FileDownloadState?> для конкретного attachment
*/
@@ -81,7 +110,6 @@ object FileDownloadManager {
* Скачивание продолжается даже если пользователь вышел из чата.
*/
fun download(
context: Context,
attachmentId: String,
downloadTag: String,
chachaKey: String,
@@ -90,132 +118,234 @@ object FileDownloadManager {
fileName: String,
savedFile: File
) {
// Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return
val normalizedAccount = accountPublicKey.trim()
val savedPath = savedFile.absolutePath
val request = DownloadRequest(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
accountPublicKey = accountPublicKey.trim(),
fileName = fileName,
savedFile = savedFile
)
requests[attachmentId] = request
startDownload(request)
}
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
fun pause(attachmentId: String) {
val current = _downloads.value[attachmentId] ?: return
if (
current.status == FileDownloadStatus.DONE ||
current.status == FileDownloadStatus.ERROR
) return
jobs[attachmentId] = scope.launch {
try {
update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
0f,
normalizedAccount,
savedPath
)
pauseRequested.add(attachmentId)
val pausedProgress = current.progress.coerceIn(0f, 0.98f)
update(
id = attachmentId,
fileName = current.fileName,
status = FileDownloadStatus.PAUSED,
progress = pausedProgress,
accountPublicKey = current.accountPublicKey,
savedPath = current.savedPath
)
// Запускаем polling прогресса из TransportManager
val progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId }
if (entry != null) {
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
val p = (entry.progress / 100f) * 0.8f
update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
p,
normalizedAccount,
savedPath
)
}
}
}
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel()
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(chachaKey)) {
downloadGroupFile(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
)
}
}
progressJob.cancel()
if (success) {
update(
attachmentId,
fileName,
FileDownloadStatus.DONE,
1f,
normalizedAccount,
savedPath
)
} else {
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} finally {
jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения
scope.launch {
delay(5000)
_downloads.update { it - attachmentId }
}
}
fun resume(attachmentId: String) {
val request = requests[attachmentId] ?: return
if (jobs[attachmentId]?.isActive == true) {
resumeAfterPause.add(attachmentId)
return
}
pauseRequested.remove(attachmentId)
startDownload(request)
}
/**
* Отменяет скачивание
*/
fun cancel(attachmentId: String) {
requests[attachmentId]?.let { req ->
encryptedPartFile(req).delete()
}
pauseRequested.remove(attachmentId)
resumeAfterPause.remove(attachmentId)
requests.remove(attachmentId)
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel()
jobs.remove(attachmentId)
_downloads.update { it - attachmentId }
}
private fun startDownload(request: DownloadRequest) {
val attachmentId = request.attachmentId
if (jobs[attachmentId]?.isActive == true) return
pauseRequested.remove(attachmentId)
val savedPath = request.savedFile.absolutePath
val encryptedPart = encryptedPartFile(request)
val resumeBase =
(_downloads.value[attachmentId]
?.takeIf { it.status == FileDownloadStatus.PAUSED }
?.progress
?: 0f).coerceIn(0f, 0.8f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.QUEUED,
resumeBase,
request.accountPublicKey,
savedPath
)
jobs[attachmentId] = scope.launch {
var progressJob: Job? = null
try {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DOWNLOADING,
resumeBase,
request.accountPublicKey,
savedPath
)
// Запускаем polling прогресса из TransportManager.
// Держим прогресс монотонным, чтобы он не дёргался вниз.
progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId } ?: return@collect
val rawCdn = (entry.progress / 100f) * 0.8f
val current = _downloads.value[attachmentId]?.progress ?: 0f
val stable = maxOf(current, rawCdn).coerceIn(0f, 0.8f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.DOWNLOADING,
stable,
request.accountPublicKey,
savedPath
)
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(request.chachaKey)) {
downloadGroupFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
}
}
if (success) {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DONE,
1f,
request.accountPublicKey,
savedPath
)
encryptedPart.delete()
requests.remove(attachmentId)
} else {
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
}
} catch (e: CancellationException) {
if (pauseRequested.remove(attachmentId)) {
val current = _downloads.value[attachmentId]
val pausedProgress = (current?.progress ?: resumeBase).coerceIn(0f, 0.98f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.PAUSED,
pausedProgress,
request.accountPublicKey,
savedPath
)
} else {
throw e
}
} catch (e: Exception) {
e.printStackTrace()
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
} finally {
progressJob?.cancel()
jobs.remove(attachmentId)
if (resumeAfterPause.remove(attachmentId)) {
scope.launch { startDownload(request) }
}
// Автоочистка только терминальных состояний.
val terminalStatus = _downloads.value[attachmentId]?.status
if (
terminalStatus == FileDownloadStatus.DONE ||
terminalStatus == FileDownloadStatus.ERROR
) {
scope.launch {
delay(5000)
val current = _downloads.value[attachmentId]
if (
current?.status == FileDownloadStatus.DONE ||
current?.status == FileDownloadStatus.ERROR
) {
_downloads.update { it - attachmentId }
}
}
}
}
}
}
// ─── internal download logic (moved from FileAttachment) ───
private suspend fun downloadGroupFile(
@@ -225,10 +355,21 @@ object FileDownloadManager {
privateKey: String,
fileName: String,
savedFile: File,
encryptedPartFile: File,
accountPublicKey: String,
savedPath: String
): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val encryptedFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
)
val encryptedContent = withContext(Dispatchers.IO) {
encryptedFile.readText(Charsets.UTF_8)
}
update(
attachmentId,
fileName,
@@ -283,11 +424,18 @@ object FileDownloadManager {
privateKey: String,
fileName: String,
savedFile: File,
encryptedPartFile: File,
accountPublicKey: String,
savedPath: String
): Boolean {
// Streaming: скачиваем во temp file
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val tempFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
)
update(
attachmentId,
fileName,
@@ -316,7 +464,7 @@ object FileDownloadManager {
savedFile
)
} finally {
tempFile.delete()
encryptedPartFile.delete()
}
}
update(
@@ -339,12 +487,19 @@ object FileDownloadManager {
savedPath: String
) {
_downloads.update { map ->
val previous = map[id]
val normalizedProgress =
when (status) {
FileDownloadStatus.DONE -> 1f
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
}
map + (
id to FileDownloadState(
attachmentId = id,
fileName = fileName,
status = status,
progress = progress,
progress = normalizedProgress,
accountPublicKey = accountPublicKey,
savedPath = savedPath
)

View File

@@ -1,20 +1,26 @@
package com.rosetta.messenger.network
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext
/**
* Состояние загрузки/скачивания файла
@@ -41,6 +47,7 @@ object TransportManager {
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
@@ -93,6 +100,8 @@ object TransportManager {
repeat(MAX_RETRIES) { attempt ->
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
if (attempt < MAX_RETRIES - 1) {
@@ -110,6 +119,54 @@ object TransportManager {
val packet = PacketRequestTransport()
ProtocolManager.sendPacket(packet)
}
/**
* Принудительно отменяет активный HTTP call для скачивания attachment.
* Нужен для pause/resume в file bubble.
*/
fun cancelDownload(id: String) {
activeDownloadCalls.remove(id)?.cancel()
_downloading.value = _downloading.value.filter { it.id != id }
}
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = client.newCall(request)
activeDownloadCalls[id] = call
cont.invokeOnCancellation {
activeDownloadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeDownloadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Download cancelled"))
} else {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
activeDownloadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
}
private fun parseContentRangeTotal(value: String?): Long? {
if (value.isNullOrBlank()) return null
// Example: "bytes 100-999/12345"
val totalPart = value.substringAfter('/').trim()
if (totalPart.isEmpty() || totalPart == "*") return null
return totalPart.toLongOrNull()
}
/**
* Загрузить файл на транспортный сервер с отслеживанием прогресса
@@ -226,17 +283,7 @@ object TransportManager {
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
val response = awaitDownloadResponse(id, request)
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
@@ -310,6 +357,7 @@ object TransportManager {
)
throw e
} finally {
activeDownloadCalls.remove(id)?.cancel()
// Удаляем из списка скачиваний
_downloading.value = _downloading.value.filter { it.id != id }
}
@@ -338,84 +386,129 @@ object TransportManager {
* @return Временный файл с зашифрованным содержимым
*/
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try {
downloadFileRawResumable(
id = id,
tag = tag,
targetFile = tempFile,
resumeFromBytes = 0L
)
} catch (e: Exception) {
tempFile.delete()
throw e
}
}
_downloading.value = _downloading.value + TransportState(id, 0)
/**
* Resumable download with HTTP Range support.
* If server supports range (206), continues from `targetFile.length()`.
* If not, safely restarts from zero and rewrites target file.
*/
suspend fun downloadFileRawResumable(
id: String,
tag: String,
targetFile: File,
resumeFromBytes: Long = 0L
): File = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
)
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
try {
withRetry {
val request = Request.Builder()
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
.coerceAtMost(existingBytes)
val requestBuilder = Request.Builder()
.url("$server/d/$tag")
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
if (startOffset > 0L) {
requestBuilder.addHeader("Range", "bytes=$startOffset-")
}
val response = awaitDownloadResponse(id, requestBuilder.build())
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength()
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
val rangeAccepted = response.code == 206
val writeFromOffset = if (rangeAccepted) startOffset else 0L
val incomingLength = body.contentLength().coerceAtLeast(0L)
val totalFromHeader = parseContentRangeTotal(response.header("Content-Range"))
val totalBytes = when {
totalFromHeader != null && totalFromHeader > 0L -> totalFromHeader
incomingLength > 0L -> writeFromOffset + incomingLength
else -> -1L
}
try {
var totalRead = 0L
val buffer = ByteArray(64 * 1024)
if (writeFromOffset == 0L && targetFile.exists()) {
targetFile.delete()
}
targetFile.parentFile?.mkdirs()
body.byteStream().use { inputStream ->
tempFile.outputStream().use { outputStream ->
while (true) {
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) break
outputStream.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (contentLength > 0) {
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
val append = writeFromOffset > 0L
var totalRead = writeFromOffset
val buffer = ByteArray(64 * 1024)
body.byteStream().use { inputStream ->
java.io.FileOutputStream(targetFile, append).use { outputStream ->
while (true) {
coroutineContext.ensureActive()
val bytesRead = try {
inputStream.read(buffer)
} catch (e: IOException) {
if (!coroutineContext.isActive) {
throw CancellationException("Download cancelled", e)
}
throw e
}
if (bytesRead == -1) break
outputStream.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (totalBytes > 0L) {
val progress =
((totalRead * 100L) / totalBytes).toInt().coerceIn(0, 99)
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
}
}
}
if (contentLength > 0 && totalRead != contentLength) {
tempFile.delete()
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
}
if (totalRead == 0L) {
tempFile.delete()
throw IOException("Empty download: 0 bytes received")
}
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
tempFile
} catch (e: Exception) {
tempFile.delete()
throw e
}
if (totalBytes > 0L && totalRead < totalBytes) {
throw IOException(
"Incomplete download: expected=$totalBytes, got=$totalRead"
)
}
if (totalRead == 0L) {
throw IOException("Empty download: 0 bytes received")
}
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog(
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
)
targetFile
}
} catch (e: Exception) {
ProtocolManager.addLog(
"❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally {
activeDownloadCalls.remove(id)?.cancel()
_downloading.value = _downloading.value.filter { it.id != id }
}
}

View File

@@ -2,13 +2,7 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.view.Gravity
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -80,7 +74,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -101,7 +94,6 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.*
@@ -128,6 +120,7 @@ import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar =
@@ -241,6 +234,8 @@ fun ChatDetailScreen(
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor
val dateHeaderBackgroundColor =
if (isDarkTheme || hasChatWallpaper) Color(0x80212121) else Color(0xCCE8E8ED)
val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator
@@ -353,13 +348,17 @@ fun ChatDetailScreen(
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
// 🎨 Управление статус баром.
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
if (!view.isInEditMode) {
SideEffect {
if (showImageViewer) {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
} else {
if (window != null && view != null) {
val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
@@ -398,6 +397,7 @@ fun ChatDetailScreen(
var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null)
} // Фото для редактирования
var pendingCameraPhotoCaption by remember { mutableStateOf("") }
// 📷 Показать встроенную камеру (без системного превью)
var showInAppCamera by remember { mutableStateOf(false) }
@@ -636,6 +636,15 @@ fun ChatDetailScreen(
// If typing, the user is obviously online — never show "offline" while typing
val isOnline = rawIsOnline || isTyping
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
val showMessageSkeleton by
produceState(initialValue = false, key1 = isLoading) {
if (!isLoading) {
value = false
return@produceState
}
delay(MESSAGE_SKELETON_VISIBILITY_DELAY_MS)
value = isLoading
}
// <20>🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState()
@@ -1163,6 +1172,37 @@ fun ChatDetailScreen(
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage }
}
val floatingDateText by remember(messagesWithDates, listState) {
derivedStateOf {
if (messagesWithDates.isEmpty()) {
return@derivedStateOf null
}
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) {
return@derivedStateOf null
}
val topVisibleItem =
visibleItems.minByOrNull { itemInfo ->
kotlin.math.abs(itemInfo.offset - layoutInfo.viewportStartOffset)
} ?: return@derivedStateOf null
val messageIndex = topVisibleItem.index
if (messageIndex !in messagesWithDates.indices) {
return@derivedStateOf null
}
getDateText(messagesWithDates[messageIndex].first.timestamp.time)
}
}
val showFloatingDateHeader by
remember(messagesWithDates, floatingDateText, isAtBottom, listState) {
derivedStateOf {
messagesWithDates.isNotEmpty() &&
floatingDateText != null &&
!isAtBottom &&
(listState.isScrollInProgress ||
listState.firstVisibleItemIndex > 0)
}
}
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
// 🔥 Скроллим только если изменился ID самого нового сообщения
@@ -2335,10 +2375,9 @@ fun ChatDetailScreen(
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
// when content paddings (bottom bar/IME) change.
if (chatWallpaperResId != null) {
TiledChatWallpaper(
ChatWallpaperBackground(
wallpaperResId = chatWallpaperResId,
modifier = Modifier.matchParentSize(),
tileScale = 0.9f
modifier = Modifier.matchParentSize()
)
} else {
Box(
@@ -2365,7 +2404,7 @@ fun ChatDetailScreen(
when {
// 🔥 СКЕЛЕТОН - показываем пока загружаются
// сообщения
isLoading -> {
showMessageSkeleton -> {
MessageSkeletonList(
isDarkTheme = isDarkTheme,
isGroupChat = isGroupChat,
@@ -2373,6 +2412,9 @@ fun ChatDetailScreen(
Modifier.fillMaxSize()
)
}
isLoading && messages.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize())
}
// Пустое состояние (нет сообщений)
messages.isEmpty() -> {
Column(
@@ -2563,12 +2605,41 @@ fun ChatDetailScreen(
isMessageBoundary(message, prevMessage)
val isGroupStart =
isMessageBoundary(message, nextMessage)
val runHeadIndex =
messageRunNewestIndex.getOrNull(
index
) ?: index
val runTailIndex =
messageRunOldestIndexByHead
.getOrNull(
runHeadIndex
)
?: runHeadIndex
val isHeadPhase =
incomingRunAvatarUiState
.showOnRunHeads
.contains(
runHeadIndex
)
val isTailPhase =
incomingRunAvatarUiState
.showOnRunTails
.contains(
runHeadIndex
)
val showIncomingGroupAvatar =
isGroupChat &&
!message.isOutgoing &&
senderPublicKeyForMessage
.isNotBlank() &&
isGroupStart
((index ==
runHeadIndex &&
isHeadPhase &&
showTail) ||
(index ==
runTailIndex &&
isTailPhase &&
isGroupStart))
Column {
if (showDate
@@ -2579,8 +2650,10 @@ fun ChatDetailScreen(
message.timestamp
.time
),
secondaryTextColor =
dateHeaderTextColor
textColor =
dateHeaderTextColor,
backgroundColor =
dateHeaderBackgroundColor
)
}
val selectionKey =
@@ -2951,6 +3024,42 @@ fun ChatDetailScreen(
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible =
showFloatingDateHeader &&
!isLoading &&
!isSelectionMode,
enter =
fadeIn(animationSpec = tween(120)) +
slideInVertically(
animationSpec =
tween(120)
) { height ->
-height / 2
},
exit =
fadeOut(animationSpec = tween(100)) +
slideOutVertically(
animationSpec =
tween(100)
) { height ->
-height / 2
},
modifier =
Modifier.align(Alignment.TopCenter)
.padding(top = 8.dp)
.zIndex(3f)
) {
floatingDateText?.let { dateText ->
DateHeader(
dateText = dateText,
textColor = dateHeaderTextColor,
backgroundColor =
dateHeaderBackgroundColor,
verticalPadding = 0.dp
)
}
}
if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
val avatarInsetPx =
with(density) {
@@ -3568,7 +3677,8 @@ fun ChatDetailScreen(
InAppCameraScreen(
onDismiss = { showInAppCamera = false },
onPhotoTaken = { photoUri ->
// Сначала редактор (skipEnterAnimation=1f), потом убираем камеру
// После камеры открываем тот же fullscreen-редактор,
// что и для фото из галереи.
pendingCameraPhotoUri = photoUri
showInAppCamera = false
}
@@ -3577,26 +3687,25 @@ fun ChatDetailScreen(
// 📷 Image Editor для фото с камеры (с caption как в Telegram)
pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen(
SimpleFullscreenPhotoOverlay(
imageUri = uri,
onDismiss = {
pendingCameraPhotoUri = null
inputFocusTrigger++
},
onSave = { editedUri ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, "")
showMediaPicker = false
},
onSaveWithCaption = { editedUri, caption ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
modifier = Modifier.fillMaxSize().zIndex(100f),
showCaptionInput = true,
caption = pendingCameraPhotoCaption,
onCaptionChange = { pendingCameraPhotoCaption = it },
isDarkTheme = isDarkTheme,
onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false
pendingCameraPhotoUri = null
pendingCameraPhotoCaption = ""
inputFocusTrigger++
},
isDarkTheme = isDarkTheme,
showCaptionInput = true,
recipientName = user.title,
skipEnterAnimation = true // Из камеры — мгновенно, без fade
onDismiss = {
pendingCameraPhotoUri = null
pendingCameraPhotoCaption = ""
inputFocusTrigger++
}
)
}
@@ -3676,60 +3785,14 @@ private fun GroupMembersSubtitleSkeleton() {
}
@Composable
private fun TiledChatWallpaper(
private fun ChatWallpaperBackground(
wallpaperResId: Int,
modifier: Modifier = Modifier,
tileScale: Float = 0.9f
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val wallpaperDrawable =
remember(wallpaperResId, tileScale, context) {
val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId)
val normalizedScale = tileScale.coerceIn(0.2f, 2f)
val scaledBitmap =
decoded?.let { original ->
if (normalizedScale == 1f) {
original
} else {
val width =
(original.width * normalizedScale)
.toInt()
.coerceAtLeast(1)
val height =
(original.height * normalizedScale)
.toInt()
.coerceAtLeast(1)
val scaled =
Bitmap.createScaledBitmap(
original,
width,
height,
true
)
if (scaled != original) {
original.recycle()
}
scaled
}
}
val safeBitmap =
scaledBitmap
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
.apply {
eraseColor(android.graphics.Color.TRANSPARENT)
}
BitmapDrawable(context.resources, safeBitmap).apply {
setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
gravity = Gravity.TOP or Gravity.START
}
}
AndroidView(
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = "Chat wallpaper",
modifier = modifier,
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } },
update = { view -> view.background = wallpaperDrawable }
contentScale = ContentScale.Crop
)
}

View File

@@ -2169,15 +2169,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🔥 Удалить сообщение (для ошибки отправки) */
fun deleteMessage(messageId: String) {
val account = myPublicKey ?: return
val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent)
// Удаляем из UI сразу на main
_messages.value = _messages.value.filter { it.id != messageId }
val updatedMessages = _messages.value.filter { it.id != messageId }
_messages.value = updatedMessages
// Синхронизируем глобальный кэш диалога, иначе удалённые сообщения могут вернуться
// при повторном открытии чата из stale cache.
updateCacheWithLimit(account, dialogKey, updatedMessages)
messageRepository.clearDialogCache(opponent)
// Удаляем из БД в IO + удаляем pin если был
viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val dialogKey = opponentKey ?: return@launch
pinnedMessageDao.removePin(account, dialogKey, messageId)
messageDao.deleteMessage(account, messageId)
if (account == opponent) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
}
}
@@ -3500,15 +3512,101 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return
}
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
val context = getApplication<Application>()
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
if (recipient == null || sender == null || privateKey == null) {
ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
)
return
}
if (isSending) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
return
}
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
val attachmentIds = imageUris.indices.map { index -> "img_${timestamp}_$index" }
val optimisticAttachments =
imageUris.mapIndexed { index, uri ->
MessageAttachment(
id = attachmentIds[index],
blob = "",
type = AttachmentType.IMAGE,
preview = "",
width = 0,
height = 0,
localUri = uri.toString()
)
}
addMessageSafely(
ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments = optimisticAttachments
)
)
_inputText.value = ""
ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
)
backgroundUploadScope.launch {
try {
val optimisticAttachmentsJson =
JSONArray().apply {
imageUris.forEachIndexed { index, uri ->
put(
JSONObject().apply {
put("id", attachmentIds[index])
put("type", AttachmentType.IMAGE.value)
put("preview", "")
put("blob", "")
put("width", 0)
put("height", 0)
put("localUri", uri.toString())
}
)
}
}.toString()
saveMessageToDatabase(
messageId = messageId,
text = text,
encryptedContent = "",
encryptedKey = "",
timestamp = timestamp,
isFromMe = true,
delivered = 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipient
)
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
timestamp = timestamp,
opponentPublicKey = recipient
)
} catch (_: Exception) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)")
}
val preparedImages =
imageUris.mapIndexedNotNull { index, uri ->
imageUris.mapIndexed { index, uri ->
val (width, height) =
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
context,
@@ -3523,7 +3621,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
)
return@mapIndexedNotNull null
throw IllegalStateException(
"group item#$index base64 conversion failed"
)
}
val blurhash =
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
@@ -3533,26 +3633,156 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
)
ImageData(
base64 = imageBase64,
blurhash = blurhash,
width = width,
height = height
)
index to
ImageData(
base64 = imageBase64,
blurhash = blurhash,
width = width,
height = height
)
}
if (preparedImages.isEmpty()) {
ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
)
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
isSending = false
return@launch
}
ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
)
withContext(Dispatchers.Main) {
sendImageGroup(preparedImages, caption)
try {
val groupStartedAt = System.currentTimeMillis()
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val isSavedMessages = sender == recipient
val networkAttachments = mutableListOf<MessageAttachment>()
val finalDbAttachments = JSONArray()
val finalAttachmentsById = mutableMapOf<String, MessageAttachment>()
for ((originalIndex, imageData) in preparedImages) {
val attachmentId = attachmentIds[originalIndex]
val encryptedImageBlob =
encryptAttachmentPayload(imageData.base64, encryptionContext)
val uploadTag =
if (!isSavedMessages) {
TransportManager.uploadFile(attachmentId, encryptedImageBlob)
} else {
""
}
val previewWithTag =
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}"
else imageData.blurhash
AttachmentFileManager.saveAttachment(
context = context,
blob = imageData.base64,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
val finalAttachment =
MessageAttachment(
id = attachmentId,
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE,
preview = previewWithTag,
width = imageData.width,
height = imageData.height,
localUri = ""
)
networkAttachments.add(finalAttachment)
finalAttachmentsById[attachmentId] = finalAttachment
finalDbAttachments.put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("blob", "")
put("width", imageData.width)
put("height", imageData.height)
}
)
}
val packet =
PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = networkAttachments
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
updateMessageStatusAndAttachmentsInDb(
messageId = messageId,
delivered = 1,
attachmentsJson = finalDbAttachments.toString()
)
withContext(Dispatchers.Main) {
_messages.value =
_messages.value.map { msg ->
if (msg.id != messageId) return@map msg
msg.copy(
status = MessageStatus.SENT,
attachments =
msg.attachments.map { current ->
val final = finalAttachmentsById[current.id]
if (final != null) {
current.copy(
preview = final.preview,
width = final.width,
height = final.height,
localUri = ""
)
} else {
current.copy(localUri = "")
}
}
)
}
updateCacheFromCurrentMessages()
}
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
timestamp = timestamp,
opponentPublicKey = recipient
)
logPhotoPipeline(
messageId,
"group-from-uri completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
)
} catch (e: Exception) {
logPhotoPipelineError(messageId, "group-from-uri", e)
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
} finally {
isSending = false
}
}
}

View File

@@ -85,6 +85,7 @@ import compose.icons.tablericons.*
import com.rosetta.messenger.ui.icons.TelegramIcons
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.hypot
@@ -108,6 +109,8 @@ private enum class DeviceResolveAction {
DECLINE
}
private const val SKELETON_VISIBILITY_DELAY_MS = 180L
// Avatar colors matching React Native app (Mantine inspired)
// Light theme colors (background lighter, text darker)
private val avatarColorsLight =
@@ -480,6 +483,9 @@ fun ChatsListScreen(
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DOWNLOADING ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.PAUSED ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DECRYPTING
@@ -1791,7 +1797,29 @@ fun ChatsListScreen(
val requests = chatsState.requests
val requestsCount = chatsState.requestsCount
val showSkeleton = isLoading
val showSkeleton by
produceState(
initialValue = false,
key1 = isLoading
) {
if (!isLoading) {
value = false
return@produceState
}
delay(SKELETON_VISIBILITY_DELAY_MS)
value = isLoading
}
val chatListState = rememberLazyListState()
var isRequestsVisible by remember { mutableStateOf(true) }
var requestsPullProgress by remember { mutableStateOf(0f) }
var lastAutoScrolledVerificationId by remember {
mutableStateOf<String?>(null)
}
val localDensity = LocalDensity.current
val requestsRevealThresholdPx =
remember(localDensity) { with(localDensity) { 28.dp.toPx() } }
val requestsHideThresholdPx =
remember(localDensity) { with(localDensity) { 16.dp.toPx() } }
AnimatedContent(
targetState = showDownloadsScreen,
@@ -1999,6 +2027,8 @@ fun ChatsListScreen(
} // Close Box wrapper
} else if (showSkeleton) {
ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (isLoading && chatsState.isEmpty) {
Box(modifier = Modifier.fillMaxSize())
} else if (chatsState.isEmpty) {
EmptyChatsState(
isDarkTheme = isDarkTheme,
@@ -2056,14 +2086,6 @@ fun ChatsListScreen(
}
}
// Track scroll direction to hide/show Requests
val chatListState = rememberLazyListState()
var isRequestsVisible by remember { mutableStateOf(true) }
var lastAutoScrolledVerificationId by remember {
mutableStateOf<String?>(null)
}
val hapticFeedback = LocalHapticFeedback.current
// When a new device confirmation banner appears at the top,
// smoothly bring the list to top so the banner is visible.
LaunchedEffect(pendingDeviceVerification?.deviceId) {
@@ -2087,44 +2109,135 @@ fun ChatsListScreen(
lastAutoScrolledVerificationId = verificationId
}
// NestedScroll — ловим направление свайпа даже без скролла
// Для появления: накапливаем pull down дельту, нужен сильный жест
val requestsNestedScroll = remember(hapticFeedback) {
var accumulatedPullDown = 0f
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
if (available.y < -10f) {
// Свайп вверх — прячем легко
accumulatedPullDown = 0f
isRequestsVisible = false
} else if (available.y > 0f && !isRequestsVisible) {
// Свайп вниз — накапливаем для появления
accumulatedPullDown += available.y
if (accumulatedPullDown > 120f) {
isRequestsVisible = true
val requestsNestedScroll =
remember(
requestsCount,
chatListState,
requestsRevealThresholdPx,
requestsHideThresholdPx,
hapticFeedback
) {
var accumulatedPullDown = 0f
var accumulatedPullUp = 0f
val pullDownLimit =
requestsRevealThresholdPx * 1.25f
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
if (source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag ||
requestsCount <= 0
) {
accumulatedPullDown = 0f
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
accumulatedPullUp = 0f
requestsPullProgress = 0f
return androidx.compose.ui.geometry.Offset.Zero
}
val atTop = !chatListState.canScrollBackward
val nearTop =
chatListState.firstVisibleItemIndex == 0 &&
chatListState.firstVisibleItemScrollOffset <=
2
if (available.y < 0f &&
isRequestsVisible &&
(atTop ||
nearTop)
) {
accumulatedPullUp += -available.y
accumulatedPullDown = 0f
requestsPullProgress = 0f
if (accumulatedPullUp >= requestsHideThresholdPx) {
isRequestsVisible = false
accumulatedPullUp = 0f
}
return androidx.compose.ui.geometry.Offset(
0f,
available.y
)
}
} else if (available.y <= 0f) {
accumulatedPullDown = 0f
}
return androidx.compose.ui.geometry.Offset.Zero
}
if (available.y >= 0f) {
accumulatedPullUp = 0f
}
override suspend fun onPostFling(
consumed: androidx.compose.ui.unit.Velocity,
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
accumulatedPullDown = 0f
return androidx.compose.ui.unit.Velocity.Zero
if (!isRequestsVisible && atTop) {
if (available.y > 0f) {
accumulatedPullDown =
(accumulatedPullDown + available.y)
.coerceAtMost(
pullDownLimit
)
requestsPullProgress =
(accumulatedPullDown / requestsRevealThresholdPx)
.coerceIn(
0f,
1.15f
)
if (accumulatedPullDown >= requestsRevealThresholdPx) {
isRequestsVisible = true
accumulatedPullDown = 0f
accumulatedPullUp = 0f
requestsPullProgress = 0f
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
}
return androidx.compose.ui.geometry.Offset(
0f,
available.y
)
}
if (available.y < 0f &&
accumulatedPullDown > 0f
) {
accumulatedPullDown =
(accumulatedPullDown + available.y)
.coerceAtLeast(
0f
)
requestsPullProgress =
(accumulatedPullDown / requestsRevealThresholdPx)
.coerceIn(
0f,
1.15f
)
return androidx.compose.ui.geometry.Offset(
0f,
available.y
)
}
} else if (!isRequestsVisible && !atTop) {
accumulatedPullDown = 0f
requestsPullProgress = 0f
}
return androidx.compose.ui.geometry.Offset.Zero
}
override suspend fun onPreFling(
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
if (!isRequestsVisible) {
accumulatedPullDown = 0f
requestsPullProgress = 0f
}
return androidx.compose.ui.unit.Velocity.Zero
}
override suspend fun onPostFling(
consumed: androidx.compose.ui.unit.Velocity,
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
accumulatedPullDown = 0f
accumulatedPullUp = 0f
requestsPullProgress = 0f
return androidx.compose.ui.unit.Velocity.Zero
}
}
}
}
LazyColumn(
state = chatListState,
@@ -2163,102 +2276,96 @@ fun ChatsListScreen(
}
}
item(key = "requests_section") {
val isRequestsSectionVisible =
requestsCount > 0 &&
isRequestsVisible
AnimatedVisibility(
visible =
isRequestsSectionVisible,
enter =
slideInVertically(
initialOffsetY = {
fullHeight ->
-fullHeight /
3
},
if (requestsCount > 0) {
item(key = "requests_section") {
val requestsSectionProgress by
animateFloatAsState(
targetValue =
if (isRequestsVisible) 1f
else requestsPullProgress,
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
expandVertically(
expandFrom =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
fadeIn(
animationSpec =
tween(
durationMillis =
180
),
initialAlpha =
0.7f
),
exit =
slideOutVertically(
targetOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
fadeOut(
animationSpec =
tween(
durationMillis =
140
spring(
dampingRatio =
Spring.DampingRatioNoBouncy,
stiffness =
Spring.StiffnessMediumLow
),
label =
"requestsSectionProgress"
)
val clampedProgress =
requestsSectionProgress
.coerceIn(
0f,
1.15f
)
val revealProgress =
FastOutSlowInEasing
.transform(
clampedProgress
.coerceIn(
0f,
1f
)
)
) {
Column {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
openRequestsRouteSafely()
val stretchOvershoot =
(clampedProgress - 1f)
.coerceAtLeast(
0f
)
val sectionHeight =
76.dp *
revealProgress +
10.dp *
stretchOvershoot
val sectionAlpha =
(0.55f + revealProgress * 0.45f)
.coerceIn(
0f,
1f
)
if (isRequestsVisible ||
sectionHeight > 0.5.dp
) {
Column {
Box(
modifier =
Modifier.fillMaxWidth()
.height(
sectionHeight
)
.clipToBounds()
.graphicsLayer {
alpha =
if (isRequestsVisible)
1f
else sectionAlpha
}
) {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
isRequestsVisible =
true
requestsPullProgress =
0f
openRequestsRouteSafely()
}
)
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
}
}
}
@@ -2332,16 +2439,7 @@ fun ChatsListScreen(
listBackgroundColor
}
Column(
modifier =
Modifier.animateItemPlacement(
animationSpec =
tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
) {
Column {
SwipeableDialogItem(
dialog =
dialog,
@@ -4383,17 +4481,17 @@ fun DialogItemContent(
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier.size(22.dp)
Modifier.size(16.dp)
.clip(CircleShape)
.background(Color(0xFFE53935)),
contentAlignment = Alignment.Center
) {
Text(
text = "!",
fontSize = 13.sp,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
lineHeight = 13.sp,
lineHeight = 10.sp,
maxLines = 1
)
}
@@ -4962,6 +5060,7 @@ private fun formatDownloadStatusText(
return when (item.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> "Paused $percent%"
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"

View File

@@ -137,6 +137,7 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
import com.rosetta.messenger.ui.components.SwipeBackContainer
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
@@ -339,6 +340,7 @@ fun GroupInfoScreen(
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2)
val groupMenuTrailingIconSize = 22.dp
LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
@@ -805,8 +807,8 @@ fun GroupInfoScreen(
swipedMemberKey = null
}
}
LaunchedEffect(swipedMemberKey) {
onSwipeBackEnabledChanged(swipedMemberKey == null)
LaunchedEffect(swipedMemberKey, showEncryptionPage) {
onSwipeBackEnabledChanged(swipedMemberKey == null && !showEncryptionPage)
}
DisposableEffect(Unit) {
onDispose {
@@ -1207,7 +1209,7 @@ fun GroupInfoScreen(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(22.dp)
modifier = Modifier.size(groupMenuTrailingIconSize)
)
}
@@ -1233,7 +1235,7 @@ fun GroupInfoScreen(
)
if (encryptionKeyLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(groupMenuTrailingIconSize),
strokeWidth = 2.dp,
color = accentColor
)
@@ -1241,12 +1243,12 @@ fun GroupInfoScreen(
val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
Box(
modifier = Modifier
.size(34.dp)
.clip(RoundedCornerShape(6.dp))
.size(groupMenuTrailingIconSize)
.clip(RoundedCornerShape(3.dp))
) {
TelegramStyleIdenticon(
keyRender = identiconKey,
size = 34.dp,
size = groupMenuTrailingIconSize,
isDarkTheme = isDarkTheme
)
}
@@ -1565,11 +1567,12 @@ fun GroupInfoScreen(
)
}
AnimatedVisibility(
visible = showEncryptionPage,
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
modifier = Modifier.fillMaxSize()
SwipeBackContainer(
isVisible = showEncryptionPage,
onBack = { showEncryptionPage = false },
isDarkTheme = isDarkTheme,
layer = 3,
propagateBackgroundProgress = false
) {
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
GroupEncryptionKeyPage(

View File

@@ -62,6 +62,7 @@ internal fun MediaGrid(
mediaItems: List<MediaItem>,
selectedItemOrder: List<Long>,
showCameraItem: Boolean = true,
cameraEnabled: Boolean = true,
gridState: LazyGridState = rememberLazyGridState(),
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
@@ -87,6 +88,7 @@ internal fun MediaGrid(
item(key = "camera_button") {
CameraGridItem(
onClick = onCameraClick,
enabled = cameraEnabled,
isDarkTheme = isDarkTheme
)
}
@@ -117,6 +119,7 @@ internal fun MediaGrid(
@Composable
internal fun CameraGridItem(
onClick: () -> Unit,
enabled: Boolean = true,
isDarkTheme: Boolean
) {
val context = LocalContext.current
@@ -147,7 +150,9 @@ internal fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED
}
DisposableEffect(lifecycleOwner, hasCameraPermission) {
val enabledState = rememberUpdatedState(enabled)
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
@@ -167,10 +172,10 @@ internal fun CameraGridItem(
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp))
.background(backgroundColor)
.clickable(onClick = onClick),
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center
) {
if (hasCameraPermission) {
if (hasCameraPermission && enabled) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx).apply {
@@ -181,6 +186,9 @@ internal fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
if (!enabledState.value) {
return@addListener
}
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also {

View File

@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
internal fun AttachAlertPhotoLayout(
state: AttachAlertUiState,
gridState: LazyGridState,
cameraEnabled: Boolean = true,
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
onItemCheckClick: (MediaItem) -> Unit,
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
mediaItems = state.visibleMediaItems,
selectedItemOrder = state.selectedItemOrder,
showCameraItem = state.visibleAlbum?.isAllMedia != false,
cameraEnabled = cameraEnabled,
gridState = gridState,
onCameraClick = onCameraClick,
onItemClick = onItemClick,

View File

@@ -122,13 +122,6 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
}
}
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/**
* Telegram-style attach alert (media picker bottom sheet).
*
@@ -741,52 +734,49 @@ fun ChatAttachAlert(
}
}
LaunchedEffect(shouldShow, state.editingItem) {
LaunchedEffect(
shouldShow,
state.editingItem,
isPickerFullScreen,
isDarkTheme,
hasNativeNavigationBar
) {
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
snapshotFlow {
PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
// Apply scrim to status bar so it matches the overlay darkness
val scrimInt = (alpha * 255).toInt().coerceIn(0, 255)
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false
}
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
val dark = isDarkTheme
val fullScreen = isPickerFullScreen
if (hasNativeNavigationBar) {
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
window.navigationBarColor = navBaseColor
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
// Telegram-like natural dim: status-bar tint follows the same scrim alpha
// as the popup overlay, so top area and content overlay always match.
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
.collect { alpha ->
if (alpha != lastAppliedAlpha) {
lastAppliedAlpha = alpha
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
}
}
}
}
// ═══════════════════════════════════════════════════════════
@@ -1062,6 +1052,7 @@ fun ChatAttachAlert(
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
state = state,
gridState = mediaGridState,
cameraEnabled = !isClosing,
onCameraClick = {
requestClose {
hideKeyboard()

View File

@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
import android.content.Intent
import android.os.SystemClock
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
@@ -333,6 +341,105 @@ enum class DownloadStatus {
ERROR
}
private enum class TelegramFileActionState {
FILE,
DOWNLOAD,
CANCEL,
PAUSE,
ERROR
}
@Composable
private fun TelegramFileActionButton(
state: TelegramFileActionState,
progress: Float?,
indeterminate: Boolean,
modifier: Modifier = Modifier
) {
val spinTransition = rememberInfiniteTransition(label = "file_action_spin")
val spin by spinTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "file_action_spin_progress"
)
val backgroundColor = if (state == TelegramFileActionState.ERROR) Color(0xFFE53935) else PrimaryBlue
val iconPainter = when (state) {
TelegramFileActionState.FILE -> TelegramIcons.File
TelegramFileActionState.DOWNLOAD -> painterResource(R.drawable.msg_download)
TelegramFileActionState.CANCEL -> TelegramIcons.Close
TelegramFileActionState.ERROR -> TelegramIcons.Close
TelegramFileActionState.PAUSE -> null
}
val iconSize =
when (state) {
TelegramFileActionState.ERROR -> 18.dp
TelegramFileActionState.PAUSE -> 18.dp
else -> 20.dp
}
val showProgressRing =
(state == TelegramFileActionState.PAUSE ||
state == TelegramFileActionState.DOWNLOAD ||
state == TelegramFileActionState.CANCEL) &&
(indeterminate || progress != null)
val sweep = when {
indeterminate -> 104f
progress != null -> (progress.coerceIn(0f, 1f) * 360f)
else -> 0f
}
val startAngle = if (indeterminate) (spin * 360f) - 90f else -90f
Box(
modifier = modifier.size(40.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
if (showProgressRing && sweep > 0f) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = 2.dp.toPx()
val inset = 3.dp.toPx()
drawArc(
color = Color.White,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
topLeft = Offset(inset, inset),
size = Size(size.width - inset * 2f, size.height - inset * 2f),
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
if (state == TelegramFileActionState.PAUSE) {
Icon(
imageVector = Icons.Default.Pause,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(iconSize)
)
} else {
Icon(
painter = iconPainter ?: TelegramIcons.File,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(iconSize)
)
}
}
}
}
/**
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
* коллаж (как в Telegram)
@@ -1454,6 +1561,8 @@ fun FileAttachment(
val context = LocalContext.current
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var downloadProgress by remember { mutableStateOf(0f) }
var isPaused by remember { mutableStateOf(false) }
var lastActionAtMs by remember { mutableLongStateOf(0L) }
// Bounce animation for icon
val iconScale = remember { Animatable(0f) }
@@ -1495,16 +1604,40 @@ fun FileAttachment(
downloadStatus = when (state.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
}
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
}
LaunchedEffect(attachment.id) {
val existingState = com.rosetta.messenger.network.FileDownloadManager.stateOf(attachment.id)
if (existingState != null) {
downloadProgress = existingState.progress
downloadStatus = when (existingState.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING ->
DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.PAUSED ->
DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING ->
DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE ->
DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR ->
DownloadStatus.ERROR
}
isPaused =
existingState.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
return@LaunchedEffect
}
// Если менеджер уже качает этот файл — подхватим состояние оттуда
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
downloadStatus = DownloadStatus.DOWNLOADING
isPaused = false
return@LaunchedEffect
}
downloadStatus = if (isDownloadTag(preview)) {
@@ -1516,6 +1649,7 @@ fun FileAttachment(
if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.DOWNLOADED // blob есть в памяти
}
isPaused = false
}
// Открыть файл через системное приложение
@@ -1551,10 +1685,8 @@ fun FileAttachment(
// 📥 Запуск скачивания через глобальный FileDownloadManager
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
downloadStatus = DownloadStatus.DOWNLOADING
downloadProgress = 0f
isPaused = false
com.rosetta.messenger.network.FileDownloadManager.download(
context = context,
attachmentId = attachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
@@ -1566,19 +1698,56 @@ fun FileAttachment(
}
}
val pauseDownload: () -> Unit = {
com.rosetta.messenger.network.FileDownloadManager.pause(attachment.id)
isPaused = true
}
val resumeDownload: () -> Unit = {
isPaused = false
com.rosetta.messenger.network.FileDownloadManager.resume(attachment.id)
}
val isSendingUpload = isOutgoing && messageStatus == MessageStatus.SENDING
val isDownloadInProgress =
!isPaused &&
(downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING)
val actionState = when {
downloadStatus == DownloadStatus.ERROR -> TelegramFileActionState.ERROR
isSendingUpload -> TelegramFileActionState.CANCEL
isDownloadInProgress -> TelegramFileActionState.PAUSE
isPaused -> TelegramFileActionState.DOWNLOAD
downloadStatus == DownloadStatus.NOT_DOWNLOADED -> TelegramFileActionState.DOWNLOAD
else -> TelegramFileActionState.FILE
}
val actionProgress = if (isDownloadInProgress || isPaused) animatedProgress else null
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
!isSendingUpload &&
(downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR ||
isDownloadInProgress ||
isPaused)
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADED -> openFile()
val now = SystemClock.elapsedRealtime()
if (now - lastActionAtMs < 220L) return@clickable
lastActionAtMs = now
when {
isPaused -> resumeDownload()
downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
else -> download()
}
}
@@ -1595,62 +1764,12 @@ fun FileAttachment(
},
contentAlignment = Alignment.Center
) {
// Круглый фон иконки
Box(
modifier =
Modifier.fillMaxSize()
.clip(CircleShape)
.background(
if (downloadStatus == DownloadStatus.ERROR)
Color(0xFFE53935)
else PrimaryBlue
),
contentAlignment = Alignment.Center
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Determinate progress like Telegram
CircularProgressIndicator(
progress = downloadProgress.coerceIn(0f, 1f),
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
}
DownloadStatus.NOT_DOWNLOADED -> {
Icon(
Icons.Default.ArrowDownward,
contentDescription = "Download",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
DownloadStatus.DOWNLOADED -> {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
DownloadStatus.ERROR -> {
Icon(
Icons.Default.Close,
contentDescription = "Error",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
else -> {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
TelegramFileActionButton(
state = actionState,
progress = actionProgress,
indeterminate = isSendingUpload,
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.width(10.dp))
@@ -1679,34 +1798,52 @@ fun FileAttachment(
PrimaryBlue
}
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
// Telegram-style: "1.2 MB / 5.4 MB"
// CDN download maps to progress 0..0.8
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
}
DownloadStatus.DECRYPTING -> {
AnimatedDotsText(
baseText = "Decrypting",
color = statusColor,
fontSize = 12.sp
)
}
else -> {
Text(
text = when (downloadStatus) {
DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension"
},
fontSize = 12.sp,
color = statusColor
)
if (isSendingUpload) {
AnimatedDotsText(
baseText = "Uploading",
color = statusColor,
fontSize = 12.sp
)
} else {
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
if (isPaused) {
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
} else {
// Telegram-style: "1.2 MB / 5.4 MB"
// CDN download maps to progress 0..0.8
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
}
}
DownloadStatus.DECRYPTING -> {
AnimatedDotsText(
baseText = "Decrypting",
color = statusColor,
fontSize = 12.sp
)
}
else -> {
Text(
text = when (downloadStatus) {
DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension"
},
fontSize = 12.sp,
color = statusColor
)
}
}
}
}

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
@@ -191,37 +192,33 @@ fun TelegramStyleMessageContent(
private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int)
/** Date header with fade-in animation */
/** Telegram-like date header chip (inline separator and floating top badge). */
@Composable
fun DateHeader(dateText: String, secondaryTextColor: Color) {
var isVisible by remember { mutableStateOf(false) }
val alpha by
animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
label = "dateAlpha"
)
LaunchedEffect(dateText) { isVisible = true }
fun DateHeader(
dateText: String,
textColor: Color,
backgroundColor: Color,
modifier: Modifier = Modifier,
verticalPadding: Dp = 12.dp
) {
Row(
modifier =
Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer {
this.alpha = alpha
},
modifier
.fillMaxWidth()
.padding(vertical = verticalPadding),
horizontalArrangement = Arrangement.Center
) {
Text(
text = dateText,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = secondaryTextColor,
color = textColor,
modifier =
Modifier.background(
color = secondaryTextColor.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp)
color = backgroundColor,
shape = RoundedCornerShape(10.dp)
)
.padding(horizontal = 12.dp, vertical = 4.dp)
.padding(horizontal = 10.dp, vertical = 3.dp)
)
}
}
@@ -861,8 +858,14 @@ fun MessageBubble(
),
verticalAlignment = Alignment.CenterVertically
) {
val senderLabelText =
senderName
.replace('\n', ' ')
.trim()
val senderLabelMaxWidth =
if (isGroupSenderAdmin) 170.dp else 220.dp
Text(
text = senderName,
text = senderLabelText,
color =
groupSenderLabelColor(
senderPublicKey,
@@ -870,6 +873,7 @@ fun MessageBubble(
),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View File

@@ -82,13 +82,6 @@ import kotlin.math.roundToInt
private const val TAG = "MediaPickerBottomSheet"
private const val ALL_MEDIA_ALBUM_ID = 0L
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/**
* Media item from gallery
*/
@@ -606,56 +599,50 @@ fun MediaPickerBottomSheet(
}
}
// Reactive updates — single snapshotFlow drives ALL system bar colors
LaunchedEffect(shouldShow, editingItem) {
// Telegram-style: system bar updates only by picker state,
// no per-frame status bar color animation.
LaunchedEffect(
shouldShow,
editingItem,
isPickerFullScreen,
isDarkTheme,
hasNativeNavigationBar
) {
if (!shouldShow || editingItem != null) return@LaunchedEffect
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
val dark = isDarkTheme
val fullScreen = isPickerFullScreen
snapshotFlow {
PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) {
// Full screen: status bar = picker background, seamless
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
// Collapsed: semi-transparent scrim
window.statusBarColor = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
)
insetsController?.isAppearanceLightStatusBars = false
}
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
if (hasNativeNavigationBar) {
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
window.navigationBarColor = navBaseColor
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
.collect { alpha ->
if (alpha != lastAppliedAlpha) {
lastAppliedAlpha = alpha
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
}
}
}
}
// Используем Popup для показа поверх клавиатуры
@@ -1047,6 +1034,7 @@ fun MediaPickerBottomSheet(
mediaItems = visibleMediaItems,
selectedItemOrder = selectedItemOrder,
showCameraItem = selectedAlbum?.isAllMedia != false,
cameraEnabled = !isClosing,
gridState = mediaGridState,
onCameraClick = {
requestClose {
@@ -1659,6 +1647,7 @@ private fun MediaGrid(
mediaItems: List<MediaItem>,
selectedItemOrder: List<Long>,
showCameraItem: Boolean = true,
cameraEnabled: Boolean = true,
gridState: LazyGridState = rememberLazyGridState(),
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
@@ -1685,6 +1674,7 @@ private fun MediaGrid(
item(key = "camera_button") {
CameraGridItem(
onClick = onCameraClick,
enabled = cameraEnabled,
isDarkTheme = isDarkTheme
)
}
@@ -1715,6 +1705,7 @@ private fun MediaGrid(
@Composable
private fun CameraGridItem(
onClick: () -> Unit,
enabled: Boolean = true,
isDarkTheme: Boolean
) {
val context = LocalContext.current
@@ -1753,7 +1744,9 @@ private fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED
}
DisposableEffect(lifecycleOwner, hasCameraPermission) {
val enabledState = rememberUpdatedState(enabled)
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
@@ -1773,10 +1766,10 @@ private fun CameraGridItem(
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp))
.background(backgroundColor)
.clickable(onClick = onClick),
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center
) {
if (hasCameraPermission) {
if (hasCameraPermission && enabled) {
// Show live camera preview
AndroidView(
factory = { ctx ->
@@ -1788,6 +1781,9 @@ private fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
if (!enabledState.value) {
return@addListener
}
val provider = cameraProviderFuture.get()
cameraProvider = provider

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.components
import android.app.Activity
import android.content.Context
import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager
@@ -20,9 +21,11 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.coroutineContext
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
@@ -174,6 +177,15 @@ fun SwipeBackContainer(
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val dismissKeyboard: () -> Unit = {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
imm.hideSoftInputFromWindow(view.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
(view.context as? Activity)?.window?.let { window ->
WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime())
}
focusManager.clearFocus(force = true)
}
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
@@ -333,6 +345,7 @@ fun SwipeBackContainer(
var totalDragX = 0f
var totalDragY = 0f
var passedSlop = false
var keyboardHiddenForGesture = false
// deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off.
@@ -359,6 +372,14 @@ fun SwipeBackContainer(
totalDragX += dragDelta.x
totalDragY += dragDelta.y
if (!keyboardHiddenForGesture &&
totalDragX > 10f &&
kotlin.math.abs(totalDragX) >
kotlin.math.abs(totalDragY)) {
dismissKeyboard()
keyboardHiddenForGesture = true
}
if (!passedSlop) {
// Child (e.g. LazyRow) already consumed — let it handle
if (change.isConsumed) break
@@ -393,17 +414,8 @@ fun SwipeBackContainer(
screenWidthPx,
active = true
)
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager.clearFocus()
dismissKeyboard()
keyboardHiddenForGesture = true
change.consume()
} else {
@@ -489,6 +501,7 @@ fun SwipeBackContainer(
shouldShow = false
dragOffset = 0f
clearSharedSwipeProgressIfOwner()
dismissKeyboard()
onBack()
} else {
offsetAnimatable.animateTo(

View File

@@ -108,6 +108,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOf
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.launch
@@ -199,19 +200,12 @@ fun OtherProfileScreen(
var avatarViewerTimestamp by remember { mutableStateOf(0L) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
var tabSwitchJob by remember { mutableStateOf<Job?>(null) }
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { OtherProfileTab.entries.size }
)
// Tab click → animate pager
LaunchedEffect(selectedTab) {
val page = OtherProfileTab.entries.indexOf(selectedTab)
if (pagerState.currentPage != page) {
pagerState.animateScrollToPage(page)
}
}
// Pager swipe → update tab + control swipe-back
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
@@ -826,7 +820,15 @@ fun OtherProfileScreen(
) {
OtherProfileSharedTabs(
selectedTab = selectedTab,
onTabSelected = { tab -> selectedTab = tab },
onTabSelected = { tab ->
val targetPage = OtherProfileTab.entries.indexOf(tab)
if (targetPage == -1) return@OtherProfileSharedTabs
selectedTab = tab
tabSwitchJob?.cancel()
tabSwitchJob = coroutineScope.launch {
runCatching { pagerState.animateScrollToPage(targetPage) }
}
},
isDarkTheme = isDarkTheme
)
}

View File

@@ -281,6 +281,7 @@ fun ThemeScreen(
WallpaperSelectorRow(
isDarkTheme = isDarkTheme,
selectedWallpaperId = wallpaperId,
wallpapers = ThemeWallpapers.forTheme(isDarkTheme),
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onWallpaperSelected = { selectedId ->
@@ -292,7 +293,12 @@ fun ThemeScreen(
)
TelegramInfoText(
text = "Selected wallpaper is used for chat backgrounds.",
text =
if (isDarkTheme) {
"Showing wallpapers for dark theme. Switch to light mode to choose light wallpapers."
} else {
"Showing wallpapers for light theme. Switch to dark mode to choose dark wallpapers."
},
secondaryTextColor = secondaryTextColor
)
@@ -454,6 +460,7 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
private fun WallpaperSelectorRow(
isDarkTheme: Boolean,
selectedWallpaperId: String,
wallpapers: List<ThemeWallpaper>,
textColor: Color,
secondaryTextColor: Color,
onWallpaperSelected: (String) -> Unit
@@ -475,7 +482,7 @@ private fun WallpaperSelectorRow(
)
}
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
items(items = wallpapers, key = { it.id }) { wallpaper ->
WallpaperSelectorItem(
title = wallpaper.name,
wallpaperResId = wallpaper.drawableRes,

View File

@@ -6,28 +6,94 @@ import com.rosetta.messenger.R
data class ThemeWallpaper(
val id: String,
val name: String,
val preferredTheme: WallpaperTheme,
@DrawableRes val drawableRes: Int
)
enum class WallpaperTheme {
DARK,
LIGHT
}
object ThemeWallpapers {
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
val all: List<ThemeWallpaper> =
listOf(
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3),
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4),
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5),
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6),
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7),
ThemeWallpaper(id = "back_8", name = "Wallpaper 6", drawableRes = R.drawable.wallpaper_back_8),
ThemeWallpaper(id = "back_9", name = "Wallpaper 7", drawableRes = R.drawable.wallpaper_back_9),
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10),
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11),
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1),
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2)
ThemeWallpaper(
id = "back_3",
name = "Wallpaper 1",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_3
),
ThemeWallpaper(
id = "back_4",
name = "Wallpaper 2",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_4
),
ThemeWallpaper(
id = "back_5",
name = "Wallpaper 3",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_5
),
ThemeWallpaper(
id = "back_6",
name = "Wallpaper 4",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_6
),
ThemeWallpaper(
id = "back_7",
name = "Wallpaper 5",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_7
),
ThemeWallpaper(
id = "back_8",
name = "Wallpaper 6",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_8
),
ThemeWallpaper(
id = "back_9",
name = "Wallpaper 7",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_9
),
ThemeWallpaper(
id = "back_10",
name = "Wallpaper 8",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_10
),
ThemeWallpaper(
id = "back_11",
name = "Wallpaper 9",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_11
),
ThemeWallpaper(
id = "back_1",
name = "Wallpaper 10",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_1
),
ThemeWallpaper(
id = "back_2",
name = "Wallpaper 11",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_2
)
)
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
return all.filter { it.preferredTheme == targetTheme }
}
@DrawableRes
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
}