v1.2.4: фиксы медиапикера, файловых загрузок и UI групп
This commit is contained in:
@@ -44,6 +44,21 @@ object ReleaseNotes {
|
||||
- На экране группы выровнены размеры иконок Encryption Key и Add Members
|
||||
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
|
||||
- Приведён к нормальному размер индикатор ошибки в чат-листе
|
||||
|
||||
Медиапикер и камера
|
||||
- Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры
|
||||
- Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения
|
||||
- Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss
|
||||
|
||||
Файлы и загрузки
|
||||
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
|
||||
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
|
||||
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
|
||||
- Обновлён экран активных загрузок: добавлен статус Paused
|
||||
|
||||
Групповые сообщения
|
||||
- Добавлен truncate для длинных имён отправителей в групповых пузырьках
|
||||
- Убраны переносы в имени отправителя в шапке группового сообщения
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import kotlinx.coroutines.*
|
||||
@@ -20,6 +19,7 @@ data class FileDownloadState(
|
||||
enum class FileDownloadStatus {
|
||||
QUEUED,
|
||||
DOWNLOADING,
|
||||
PAUSED,
|
||||
DECRYPTING,
|
||||
DONE,
|
||||
ERROR
|
||||
@@ -35,6 +35,22 @@ 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
|
||||
)
|
||||
|
||||
// ─── helpers ───
|
||||
|
||||
@@ -67,9 +83,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 +104,6 @@ object FileDownloadManager {
|
||||
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||
*/
|
||||
fun download(
|
||||
context: Context,
|
||||
attachmentId: String,
|
||||
downloadTag: String,
|
||||
chachaKey: String,
|
||||
@@ -90,132 +112,232 @@ 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) {
|
||||
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 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.
|
||||
// При resume удерживаем плавный прогресс без визуального отката назад.
|
||||
progressJob = launch {
|
||||
TransportManager.downloading.collect { list ->
|
||||
val entry = list.find { it.id == attachmentId } ?: return@collect
|
||||
val rawCdn = (entry.progress / 100f) * 0.8f
|
||||
val mapped = if (resumeBase > 0f) {
|
||||
val normalized = (rawCdn / 0.8f).coerceIn(0f, 1f)
|
||||
resumeBase + (0.8f - resumeBase) * normalized
|
||||
} else {
|
||||
rawCdn
|
||||
}
|
||||
val current = _downloads.value[attachmentId]?.progress ?: 0f
|
||||
val stable = maxOf(current, mapped).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,
|
||||
accountPublicKey = request.accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
} else {
|
||||
downloadDirectFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = request.downloadTag,
|
||||
chachaKey = request.chachaKey,
|
||||
privateKey = request.privateKey,
|
||||
fileName = request.fileName,
|
||||
savedFile = request.savedFile,
|
||||
accountPublicKey = request.accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DONE,
|
||||
1f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} 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(
|
||||
@@ -339,12 +461,19 @@ object FileDownloadManager {
|
||||
savedPath: String
|
||||
) {
|
||||
_downloads.update { map ->
|
||||
val previous = map[id]
|
||||
val normalizedProgress =
|
||||
when (status) {
|
||||
FileDownloadStatus.DONE -> 1f
|
||||
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
|
||||
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
|
||||
}
|
||||
map + (
|
||||
id to FileDownloadState(
|
||||
attachmentId = id,
|
||||
fileName = fileName,
|
||||
status = status,
|
||||
progress = progress,
|
||||
progress = normalizedProgress,
|
||||
accountPublicKey = accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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
|
||||
@@ -41,6 +44,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 +97,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 +116,46 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить файл на транспортный сервер с отслеживанием прогресса
|
||||
@@ -226,17 +272,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 +346,7 @@ object TransportManager {
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
// Удаляем из списка скачиваний
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
@@ -350,16 +387,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}")
|
||||
@@ -416,6 +444,7 @@ object TransportManager {
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,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
|
||||
|
||||
@@ -483,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
|
||||
@@ -5057,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"
|
||||
|
||||
@@ -62,6 +62,7 @@ internal fun MediaGrid(
|
||||
mediaItems: List<MediaItem>,
|
||||
selectedItemOrder: List<Long>,
|
||||
showCameraItem: Boolean = true,
|
||||
cameraEnabled: Boolean = true,
|
||||
gridState: LazyGridState = rememberLazyGridState(),
|
||||
onCameraClick: () -> Unit,
|
||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||
@@ -87,6 +88,7 @@ internal fun MediaGrid(
|
||||
item(key = "camera_button") {
|
||||
CameraGridItem(
|
||||
onClick = onCameraClick,
|
||||
enabled = cameraEnabled,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
@@ -117,6 +119,7 @@ internal fun MediaGrid(
|
||||
@Composable
|
||||
internal fun CameraGridItem(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -147,7 +150,9 @@ internal fun CameraGridItem(
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
||||
val enabledState = rememberUpdatedState(enabled)
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||
onDispose {
|
||||
val provider = cameraProvider
|
||||
val preview = previewUseCase
|
||||
@@ -167,10 +172,10 @@ internal fun CameraGridItem(
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasCameraPermission) {
|
||||
if (hasCameraPermission && enabled) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx).apply {
|
||||
@@ -181,6 +186,9 @@ internal fun CameraGridItem(
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
if (!enabledState.value) {
|
||||
return@addListener
|
||||
}
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
val preview = Preview.Builder().build().also {
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
|
||||
internal fun AttachAlertPhotoLayout(
|
||||
state: AttachAlertUiState,
|
||||
gridState: LazyGridState,
|
||||
cameraEnabled: Boolean = true,
|
||||
onCameraClick: () -> Unit,
|
||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||
onItemCheckClick: (MediaItem) -> Unit,
|
||||
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
|
||||
mediaItems = state.visibleMediaItems,
|
||||
selectedItemOrder = state.selectedItemOrder,
|
||||
showCameraItem = state.visibleAlbum?.isAllMedia != false,
|
||||
cameraEnabled = cameraEnabled,
|
||||
gridState = gridState,
|
||||
onCameraClick = onCameraClick,
|
||||
onItemClick = onItemClick,
|
||||
|
||||
@@ -122,13 +122,6 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Telegram-style attach alert (media picker bottom sheet).
|
||||
*
|
||||
@@ -741,52 +734,49 @@ fun ChatAttachAlert(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldShow, state.editingItem) {
|
||||
LaunchedEffect(
|
||||
shouldShow,
|
||||
state.editingItem,
|
||||
isPickerFullScreen,
|
||||
isDarkTheme,
|
||||
hasNativeNavigationBar
|
||||
) {
|
||||
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
// Apply scrim to status bar so it matches the overlay darkness
|
||||
val scrimInt = (alpha * 255).toInt().coerceIn(0, 255)
|
||||
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
if (hasNativeNavigationBar) {
|
||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
val dark = isDarkTheme
|
||||
val fullScreen = isPickerFullScreen
|
||||
|
||||
if (hasNativeNavigationBar) {
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
window.navigationBarColor = navBaseColor
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
|
||||
// Telegram-like natural dim: status-bar tint follows the same scrim alpha
|
||||
// as the popup overlay, so top area and content overlay always match.
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
var lastAppliedAlpha = -1
|
||||
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
|
||||
.collect { alpha ->
|
||||
if (alpha != lastAppliedAlpha) {
|
||||
lastAppliedAlpha = alpha
|
||||
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1062,6 +1052,7 @@ fun ChatAttachAlert(
|
||||
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
|
||||
state = state,
|
||||
gridState = mediaGridState,
|
||||
cameraEnabled = !isClosing,
|
||||
onCameraClick = {
|
||||
requestClose {
|
||||
hideKeyboard()
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.content.FileProvider
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
@@ -333,6 +341,105 @@ enum class DownloadStatus {
|
||||
ERROR
|
||||
}
|
||||
|
||||
private enum class TelegramFileActionState {
|
||||
FILE,
|
||||
DOWNLOAD,
|
||||
CANCEL,
|
||||
PAUSE,
|
||||
ERROR
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TelegramFileActionButton(
|
||||
state: TelegramFileActionState,
|
||||
progress: Float?,
|
||||
indeterminate: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spinTransition = rememberInfiniteTransition(label = "file_action_spin")
|
||||
val spin by spinTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "file_action_spin_progress"
|
||||
)
|
||||
|
||||
val backgroundColor = if (state == TelegramFileActionState.ERROR) Color(0xFFE53935) else PrimaryBlue
|
||||
val iconPainter = when (state) {
|
||||
TelegramFileActionState.FILE -> TelegramIcons.File
|
||||
TelegramFileActionState.DOWNLOAD -> painterResource(R.drawable.msg_download)
|
||||
TelegramFileActionState.CANCEL -> TelegramIcons.Close
|
||||
TelegramFileActionState.ERROR -> TelegramIcons.Close
|
||||
TelegramFileActionState.PAUSE -> null
|
||||
}
|
||||
val iconSize =
|
||||
when (state) {
|
||||
TelegramFileActionState.ERROR -> 18.dp
|
||||
TelegramFileActionState.PAUSE -> 18.dp
|
||||
else -> 20.dp
|
||||
}
|
||||
|
||||
val showProgressRing =
|
||||
(state == TelegramFileActionState.PAUSE ||
|
||||
state == TelegramFileActionState.DOWNLOAD ||
|
||||
state == TelegramFileActionState.CANCEL) &&
|
||||
(indeterminate || progress != null)
|
||||
val sweep = when {
|
||||
indeterminate -> 104f
|
||||
progress != null -> (progress.coerceIn(0f, 1f) * 360f)
|
||||
else -> 0f
|
||||
}
|
||||
val startAngle = if (indeterminate) (spin * 360f) - 90f else -90f
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (showProgressRing && sweep > 0f) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
val inset = 3.dp.toPx()
|
||||
drawArc(
|
||||
color = Color.White,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweep,
|
||||
useCenter = false,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = Size(size.width - inset * 2f, size.height - inset * 2f),
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state == TelegramFileActionState.PAUSE) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Pause,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = iconPainter ?: TelegramIcons.File,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
|
||||
* коллаж (как в Telegram)
|
||||
@@ -1454,6 +1561,8 @@ fun FileAttachment(
|
||||
val context = LocalContext.current
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
var downloadProgress by remember { mutableStateOf(0f) }
|
||||
var isPaused by remember { mutableStateOf(false) }
|
||||
var lastActionAtMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// Bounce animation for icon
|
||||
val iconScale = remember { Animatable(0f) }
|
||||
@@ -1495,16 +1604,40 @@ fun FileAttachment(
|
||||
downloadStatus = when (state.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||
}
|
||||
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||
}
|
||||
|
||||
LaunchedEffect(attachment.id) {
|
||||
val existingState = com.rosetta.messenger.network.FileDownloadManager.stateOf(attachment.id)
|
||||
if (existingState != null) {
|
||||
downloadProgress = existingState.progress
|
||||
downloadStatus = when (existingState.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING ->
|
||||
DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.PAUSED ->
|
||||
DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING ->
|
||||
DownloadStatus.DECRYPTING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE ->
|
||||
DownloadStatus.DOWNLOADED
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR ->
|
||||
DownloadStatus.ERROR
|
||||
}
|
||||
isPaused =
|
||||
existingState.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
isPaused = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
@@ -1516,6 +1649,7 @@ fun FileAttachment(
|
||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
||||
}
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
// Открыть файл через системное приложение
|
||||
@@ -1551,10 +1685,8 @@ fun FileAttachment(
|
||||
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||
val download: () -> Unit = {
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
downloadProgress = 0f
|
||||
isPaused = false
|
||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||
context = context,
|
||||
attachmentId = attachment.id,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
@@ -1566,19 +1698,56 @@ fun FileAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
val pauseDownload: () -> Unit = {
|
||||
com.rosetta.messenger.network.FileDownloadManager.pause(attachment.id)
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
val resumeDownload: () -> Unit = {
|
||||
isPaused = false
|
||||
com.rosetta.messenger.network.FileDownloadManager.resume(attachment.id)
|
||||
}
|
||||
|
||||
val isSendingUpload = isOutgoing && messageStatus == MessageStatus.SENDING
|
||||
val isDownloadInProgress =
|
||||
!isPaused &&
|
||||
(downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||
downloadStatus == DownloadStatus.DECRYPTING)
|
||||
val actionState = when {
|
||||
downloadStatus == DownloadStatus.ERROR -> TelegramFileActionState.ERROR
|
||||
isSendingUpload -> TelegramFileActionState.CANCEL
|
||||
isDownloadInProgress -> TelegramFileActionState.PAUSE
|
||||
isPaused -> TelegramFileActionState.DOWNLOAD
|
||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED -> TelegramFileActionState.DOWNLOAD
|
||||
else -> TelegramFileActionState.FILE
|
||||
}
|
||||
val actionProgress = if (isDownloadInProgress || isPaused) animatedProgress else null
|
||||
|
||||
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
enabled =
|
||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.ERROR
|
||||
!isSendingUpload &&
|
||||
(downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||
downloadStatus == DownloadStatus.ERROR ||
|
||||
isDownloadInProgress ||
|
||||
isPaused)
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADED -> openFile()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastActionAtMs < 220L) return@clickable
|
||||
lastActionAtMs = now
|
||||
|
||||
when {
|
||||
isPaused -> resumeDownload()
|
||||
downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
|
||||
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
|
||||
else -> download()
|
||||
}
|
||||
}
|
||||
@@ -1595,62 +1764,12 @@ fun FileAttachment(
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Круглый фон иконки
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (downloadStatus == DownloadStatus.ERROR)
|
||||
Color(0xFFE53935)
|
||||
else PrimaryBlue
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
// Determinate progress like Telegram
|
||||
CircularProgressIndicator(
|
||||
progress = downloadProgress.coerceIn(0f, 1f),
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
DownloadStatus.NOT_DOWNLOADED -> {
|
||||
Icon(
|
||||
Icons.Default.ArrowDownward,
|
||||
contentDescription = "Download",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DownloadStatus.DOWNLOADED -> {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DownloadStatus.ERROR -> {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Error",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TelegramFileActionButton(
|
||||
state = actionState,
|
||||
progress = actionProgress,
|
||||
indeterminate = isSendingUpload,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
@@ -1679,34 +1798,52 @@ fun FileAttachment(
|
||||
PrimaryBlue
|
||||
}
|
||||
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||
// CDN download maps to progress 0..0.8
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
DownloadStatus.DECRYPTING -> {
|
||||
AnimatedDotsText(
|
||||
baseText = "Decrypting",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = when (downloadStatus) {
|
||||
DownloadStatus.ERROR -> "File expired"
|
||||
else -> "${formatFileSize(fileSize)} $fileExtension"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
if (isSendingUpload) {
|
||||
AnimatedDotsText(
|
||||
baseText = "Uploading",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
} else {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
if (isPaused) {
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
} else {
|
||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||
// CDN download maps to progress 0..0.8
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
DownloadStatus.DECRYPTING -> {
|
||||
AnimatedDotsText(
|
||||
baseText = "Decrypting",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = when (downloadStatus) {
|
||||
DownloadStatus.ERROR -> "File expired"
|
||||
else -> "${formatFileSize(fileSize)} $fileExtension"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,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,
|
||||
@@ -867,6 +873,7 @@ fun MessageBubble(
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
@@ -82,13 +82,6 @@ import kotlin.math.roundToInt
|
||||
private const val TAG = "MediaPickerBottomSheet"
|
||||
private const val ALL_MEDIA_ALBUM_ID = 0L
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Media item from gallery
|
||||
*/
|
||||
@@ -606,56 +599,50 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive updates — single snapshotFlow drives ALL system bar colors
|
||||
LaunchedEffect(shouldShow, editingItem) {
|
||||
// Telegram-style: system bar updates only by picker state,
|
||||
// no per-frame status bar color animation.
|
||||
LaunchedEffect(
|
||||
shouldShow,
|
||||
editingItem,
|
||||
isPickerFullScreen,
|
||||
isDarkTheme,
|
||||
hasNativeNavigationBar
|
||||
) {
|
||||
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
||||
val dark = isDarkTheme
|
||||
val fullScreen = isPickerFullScreen
|
||||
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
// Full screen: status bar = picker background, seamless
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
// Collapsed: semi-transparent scrim
|
||||
window.statusBarColor = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
|
||||
)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
if (hasNativeNavigationBar) {
|
||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
if (hasNativeNavigationBar) {
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
window.navigationBarColor = navBaseColor
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
|
||||
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
} else {
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
var lastAppliedAlpha = -1
|
||||
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
|
||||
.collect { alpha ->
|
||||
if (alpha != lastAppliedAlpha) {
|
||||
lastAppliedAlpha = alpha
|
||||
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Используем Popup для показа поверх клавиатуры
|
||||
@@ -1047,6 +1034,7 @@ fun MediaPickerBottomSheet(
|
||||
mediaItems = visibleMediaItems,
|
||||
selectedItemOrder = selectedItemOrder,
|
||||
showCameraItem = selectedAlbum?.isAllMedia != false,
|
||||
cameraEnabled = !isClosing,
|
||||
gridState = mediaGridState,
|
||||
onCameraClick = {
|
||||
requestClose {
|
||||
@@ -1659,6 +1647,7 @@ private fun MediaGrid(
|
||||
mediaItems: List<MediaItem>,
|
||||
selectedItemOrder: List<Long>,
|
||||
showCameraItem: Boolean = true,
|
||||
cameraEnabled: Boolean = true,
|
||||
gridState: LazyGridState = rememberLazyGridState(),
|
||||
onCameraClick: () -> Unit,
|
||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||
@@ -1685,6 +1674,7 @@ private fun MediaGrid(
|
||||
item(key = "camera_button") {
|
||||
CameraGridItem(
|
||||
onClick = onCameraClick,
|
||||
enabled = cameraEnabled,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
@@ -1715,6 +1705,7 @@ private fun MediaGrid(
|
||||
@Composable
|
||||
private fun CameraGridItem(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -1753,7 +1744,9 @@ private fun CameraGridItem(
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
||||
val enabledState = rememberUpdatedState(enabled)
|
||||
|
||||
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||
onDispose {
|
||||
val provider = cameraProvider
|
||||
val preview = previewUseCase
|
||||
@@ -1773,10 +1766,10 @@ private fun CameraGridItem(
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasCameraPermission) {
|
||||
if (hasCameraPermission && enabled) {
|
||||
// Show live camera preview
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
@@ -1788,6 +1781,9 @@ private fun CameraGridItem(
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
if (!enabledState.value) {
|
||||
return@addListener
|
||||
}
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
|
||||
|
||||
Reference in New Issue
Block a user