v1.2.4: фиксы медиапикера, файловых загрузок и UI групп
This commit is contained in:
@@ -44,6 +44,21 @@ object ReleaseNotes {
|
|||||||
- На экране группы выровнены размеры иконок Encryption Key и Add Members
|
- На экране группы выровнены размеры иконок Encryption Key и Add Members
|
||||||
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
|
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
|
||||||
- Приведён к нормальному размер индикатор ошибки в чат-листе
|
- Приведён к нормальному размер индикатор ошибки в чат-листе
|
||||||
|
|
||||||
|
Медиапикер и камера
|
||||||
|
- Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры
|
||||||
|
- Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения
|
||||||
|
- Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss
|
||||||
|
|
||||||
|
Файлы и загрузки
|
||||||
|
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
|
||||||
|
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
|
||||||
|
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
|
||||||
|
- Обновлён экран активных загрузок: добавлен статус Paused
|
||||||
|
|
||||||
|
Групповые сообщения
|
||||||
|
- Добавлен truncate для длинных имён отправителей в групповых пузырьках
|
||||||
|
- Убраны переносы в имени отправителя в шапке группового сообщения
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -20,6 +19,7 @@ data class FileDownloadState(
|
|||||||
enum class FileDownloadStatus {
|
enum class FileDownloadStatus {
|
||||||
QUEUED,
|
QUEUED,
|
||||||
DOWNLOADING,
|
DOWNLOADING,
|
||||||
|
PAUSED,
|
||||||
DECRYPTING,
|
DECRYPTING,
|
||||||
DONE,
|
DONE,
|
||||||
ERROR
|
ERROR
|
||||||
@@ -35,6 +35,22 @@ object FileDownloadManager {
|
|||||||
|
|
||||||
/** Текущие Job'ы — чтобы не запускать повторно */
|
/** Текущие Job'ы — чтобы не запускать повторно */
|
||||||
private val jobs = mutableMapOf<String, Job>()
|
private val jobs = mutableMapOf<String, Job>()
|
||||||
|
/** Последние параметры скачивания — нужны для resume */
|
||||||
|
private val requests = mutableMapOf<String, DownloadRequest>()
|
||||||
|
/** Флаг, что cancel произошёл именно как user pause */
|
||||||
|
private val pauseRequested = mutableSetOf<String>()
|
||||||
|
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
|
||||||
|
private val resumeAfterPause = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private data class DownloadRequest(
|
||||||
|
val attachmentId: String,
|
||||||
|
val downloadTag: String,
|
||||||
|
val chachaKey: String,
|
||||||
|
val privateKey: String,
|
||||||
|
val accountPublicKey: String,
|
||||||
|
val fileName: String,
|
||||||
|
val savedFile: File
|
||||||
|
)
|
||||||
|
|
||||||
// ─── helpers ───
|
// ─── helpers ───
|
||||||
|
|
||||||
@@ -67,9 +83,16 @@ object FileDownloadManager {
|
|||||||
*/
|
*/
|
||||||
fun isDownloading(attachmentId: String): Boolean {
|
fun isDownloading(attachmentId: String): Boolean {
|
||||||
val state = _downloads.value[attachmentId] ?: return false
|
val state = _downloads.value[attachmentId] ?: return false
|
||||||
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
|
return state.status == FileDownloadStatus.QUEUED ||
|
||||||
|
state.status == FileDownloadStatus.DOWNLOADING ||
|
||||||
|
state.status == FileDownloadStatus.DECRYPTING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isPaused(attachmentId: String): Boolean =
|
||||||
|
_downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED
|
||||||
|
|
||||||
|
fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
||||||
*/
|
*/
|
||||||
@@ -81,7 +104,6 @@ object FileDownloadManager {
|
|||||||
* Скачивание продолжается даже если пользователь вышел из чата.
|
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||||
*/
|
*/
|
||||||
fun download(
|
fun download(
|
||||||
context: Context,
|
|
||||||
attachmentId: String,
|
attachmentId: String,
|
||||||
downloadTag: String,
|
downloadTag: String,
|
||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
@@ -90,132 +112,232 @@ object FileDownloadManager {
|
|||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File
|
savedFile: File
|
||||||
) {
|
) {
|
||||||
// Уже в процессе?
|
val request = DownloadRequest(
|
||||||
if (jobs[attachmentId]?.isActive == true) return
|
|
||||||
val normalizedAccount = accountPublicKey.trim()
|
|
||||||
val savedPath = savedFile.absolutePath
|
|
||||||
|
|
||||||
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
|
|
||||||
|
|
||||||
jobs[attachmentId] = scope.launch {
|
|
||||||
try {
|
|
||||||
update(
|
|
||||||
attachmentId,
|
|
||||||
fileName,
|
|
||||||
FileDownloadStatus.DOWNLOADING,
|
|
||||||
0f,
|
|
||||||
normalizedAccount,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val success = withContext(Dispatchers.IO) {
|
|
||||||
if (isGroupStoredKey(chachaKey)) {
|
|
||||||
downloadGroupFile(
|
|
||||||
attachmentId = attachmentId,
|
attachmentId = attachmentId,
|
||||||
downloadTag = downloadTag,
|
downloadTag = downloadTag,
|
||||||
chachaKey = chachaKey,
|
chachaKey = chachaKey,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
|
accountPublicKey = accountPublicKey.trim(),
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
savedFile = savedFile,
|
savedFile = savedFile
|
||||||
accountPublicKey = normalizedAccount,
|
|
||||||
savedPath = savedPath
|
|
||||||
)
|
)
|
||||||
} else {
|
requests[attachmentId] = request
|
||||||
downloadDirectFile(
|
startDownload(request)
|
||||||
attachmentId = attachmentId,
|
|
||||||
downloadTag = downloadTag,
|
|
||||||
chachaKey = chachaKey,
|
|
||||||
privateKey = privateKey,
|
|
||||||
fileName = fileName,
|
|
||||||
savedFile = savedFile,
|
|
||||||
accountPublicKey = normalizedAccount,
|
|
||||||
savedPath = savedPath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
progressJob.cancel()
|
fun pause(attachmentId: String) {
|
||||||
|
val current = _downloads.value[attachmentId] ?: return
|
||||||
|
if (
|
||||||
|
current.status == FileDownloadStatus.DONE ||
|
||||||
|
current.status == FileDownloadStatus.ERROR
|
||||||
|
) return
|
||||||
|
|
||||||
if (success) {
|
pauseRequested.add(attachmentId)
|
||||||
|
val pausedProgress = current.progress.coerceIn(0f, 0.98f)
|
||||||
update(
|
update(
|
||||||
attachmentId,
|
id = attachmentId,
|
||||||
fileName,
|
fileName = current.fileName,
|
||||||
FileDownloadStatus.DONE,
|
status = FileDownloadStatus.PAUSED,
|
||||||
1f,
|
progress = pausedProgress,
|
||||||
normalizedAccount,
|
accountPublicKey = current.accountPublicKey,
|
||||||
savedPath
|
savedPath = current.savedPath
|
||||||
)
|
|
||||||
} else {
|
|
||||||
update(
|
|
||||||
attachmentId,
|
|
||||||
fileName,
|
|
||||||
FileDownloadStatus.ERROR,
|
|
||||||
0f,
|
|
||||||
normalizedAccount,
|
|
||||||
savedPath
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TransportManager.cancelDownload(attachmentId)
|
||||||
|
jobs[attachmentId]?.cancel()
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
fun resume(attachmentId: String) {
|
||||||
} catch (e: Exception) {
|
val request = requests[attachmentId] ?: return
|
||||||
e.printStackTrace()
|
if (jobs[attachmentId]?.isActive == true) {
|
||||||
update(
|
resumeAfterPause.add(attachmentId)
|
||||||
attachmentId,
|
return
|
||||||
fileName,
|
|
||||||
FileDownloadStatus.ERROR,
|
|
||||||
0f,
|
|
||||||
normalizedAccount,
|
|
||||||
savedPath
|
|
||||||
)
|
|
||||||
} catch (_: OutOfMemoryError) {
|
|
||||||
System.gc()
|
|
||||||
update(
|
|
||||||
attachmentId,
|
|
||||||
fileName,
|
|
||||||
FileDownloadStatus.ERROR,
|
|
||||||
0f,
|
|
||||||
normalizedAccount,
|
|
||||||
savedPath
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
jobs.remove(attachmentId)
|
|
||||||
// Автоочистка через 5 секунд после завершения
|
|
||||||
scope.launch {
|
|
||||||
delay(5000)
|
|
||||||
_downloads.update { it - attachmentId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
pauseRequested.remove(attachmentId)
|
||||||
|
startDownload(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отменяет скачивание
|
* Отменяет скачивание
|
||||||
*/
|
*/
|
||||||
fun cancel(attachmentId: String) {
|
fun cancel(attachmentId: String) {
|
||||||
|
pauseRequested.remove(attachmentId)
|
||||||
|
resumeAfterPause.remove(attachmentId)
|
||||||
|
requests.remove(attachmentId)
|
||||||
|
TransportManager.cancelDownload(attachmentId)
|
||||||
jobs[attachmentId]?.cancel()
|
jobs[attachmentId]?.cancel()
|
||||||
jobs.remove(attachmentId)
|
jobs.remove(attachmentId)
|
||||||
_downloads.update { it - attachmentId }
|
_downloads.update { it - attachmentId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startDownload(request: DownloadRequest) {
|
||||||
|
val attachmentId = request.attachmentId
|
||||||
|
if (jobs[attachmentId]?.isActive == true) return
|
||||||
|
|
||||||
|
pauseRequested.remove(attachmentId)
|
||||||
|
|
||||||
|
val savedPath = request.savedFile.absolutePath
|
||||||
|
val 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) ───
|
// ─── internal download logic (moved from FileAttachment) ───
|
||||||
|
|
||||||
private suspend fun downloadGroupFile(
|
private suspend fun downloadGroupFile(
|
||||||
@@ -339,12 +461,19 @@ object FileDownloadManager {
|
|||||||
savedPath: String
|
savedPath: String
|
||||||
) {
|
) {
|
||||||
_downloads.update { map ->
|
_downloads.update { map ->
|
||||||
|
val previous = map[id]
|
||||||
|
val normalizedProgress =
|
||||||
|
when (status) {
|
||||||
|
FileDownloadStatus.DONE -> 1f
|
||||||
|
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
|
||||||
|
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
map + (
|
map + (
|
||||||
id to FileDownloadState(
|
id to FileDownloadState(
|
||||||
attachmentId = id,
|
attachmentId = id,
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
status = status,
|
status = status,
|
||||||
progress = progress,
|
progress = normalizedProgress,
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
savedPath = savedPath
|
savedPath = savedPath
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
@@ -41,6 +44,7 @@ object TransportManager {
|
|||||||
|
|
||||||
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||||
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||||
|
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
.connectTimeout(60, TimeUnit.SECONDS)
|
||||||
@@ -93,6 +97,8 @@ object TransportManager {
|
|||||||
repeat(MAX_RETRIES) { attempt ->
|
repeat(MAX_RETRIES) { attempt ->
|
||||||
try {
|
try {
|
||||||
return block()
|
return block()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
||||||
if (attempt < MAX_RETRIES - 1) {
|
if (attempt < MAX_RETRIES - 1) {
|
||||||
@@ -111,6 +117,46 @@ object TransportManager {
|
|||||||
ProtocolManager.sendPacket(packet)
|
ProtocolManager.sendPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принудительно отменяет активный HTTP call для скачивания attachment.
|
||||||
|
* Нужен для pause/resume в file bubble.
|
||||||
|
*/
|
||||||
|
fun cancelDownload(id: String) {
|
||||||
|
activeDownloadCalls.remove(id)?.cancel()
|
||||||
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val call = client.newCall(request)
|
||||||
|
activeDownloadCalls[id] = call
|
||||||
|
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
activeDownloadCalls.remove(id, call)
|
||||||
|
call.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
activeDownloadCalls.remove(id, call)
|
||||||
|
if (call.isCanceled()) {
|
||||||
|
cont.cancel(CancellationException("Download cancelled"))
|
||||||
|
} else {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
activeDownloadCalls.remove(id, call)
|
||||||
|
if (cont.isCancelled) {
|
||||||
|
response.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Загрузить файл на транспортный сервер с отслеживанием прогресса
|
* Загрузить файл на транспортный сервер с отслеживанием прогресса
|
||||||
* @param id Уникальный ID файла
|
* @param id Уникальный ID файла
|
||||||
@@ -226,17 +272,7 @@ object TransportManager {
|
|||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = suspendCoroutine<Response> { cont ->
|
val response = awaitDownloadResponse(id, request)
|
||||||
client.newCall(request).enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
cont.resume(response)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw IOException("Download failed: ${response.code}")
|
throw IOException("Download failed: ${response.code}")
|
||||||
@@ -310,6 +346,7 @@ object TransportManager {
|
|||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
activeDownloadCalls.remove(id)?.cancel()
|
||||||
// Удаляем из списка скачиваний
|
// Удаляем из списка скачиваний
|
||||||
_downloading.value = _downloading.value.filter { it.id != id }
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
}
|
}
|
||||||
@@ -350,16 +387,7 @@ object TransportManager {
|
|||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = suspendCoroutine<Response> { cont ->
|
val response = awaitDownloadResponse(id, request)
|
||||||
client.newCall(request).enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
cont.resume(response)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw IOException("Download failed: ${response.code}")
|
throw IOException("Download failed: ${response.code}")
|
||||||
@@ -416,6 +444,7 @@ object TransportManager {
|
|||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
activeDownloadCalls.remove(id)?.cancel()
|
||||||
_downloading.value = _downloading.value.filter { it.id != id }
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,13 +348,17 @@ fun ChatDetailScreen(
|
|||||||
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||||
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
|
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
// 🎨 Управление статус баром.
|
||||||
|
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
|
||||||
|
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
if (showImageViewer) {
|
if (showImageViewer) {
|
||||||
SystemBarsStyleUtils.applyFullscreenDark(window, view)
|
SystemBarsStyleUtils.applyFullscreenDark(window, view)
|
||||||
} else {
|
} else {
|
||||||
if (window != null && view != null) {
|
val isOverlayControllingSystemBars = showMediaPicker
|
||||||
|
|
||||||
|
if (!isOverlayControllingSystemBars && window != null && view != null) {
|
||||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
ic.isAppearanceLightStatusBars = false
|
ic.isAppearanceLightStatusBars = false
|
||||||
|
|||||||
@@ -483,6 +483,9 @@ fun ChatsListScreen(
|
|||||||
it.status ==
|
it.status ==
|
||||||
com.rosetta.messenger.network.FileDownloadStatus
|
com.rosetta.messenger.network.FileDownloadStatus
|
||||||
.DOWNLOADING ||
|
.DOWNLOADING ||
|
||||||
|
it.status ==
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus
|
||||||
|
.PAUSED ||
|
||||||
it.status ==
|
it.status ==
|
||||||
com.rosetta.messenger.network.FileDownloadStatus
|
com.rosetta.messenger.network.FileDownloadStatus
|
||||||
.DECRYPTING
|
.DECRYPTING
|
||||||
@@ -5057,6 +5060,7 @@ private fun formatDownloadStatusText(
|
|||||||
return when (item.status) {
|
return when (item.status) {
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
|
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> "Paused $percent%"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
|
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
|
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"
|
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ internal fun MediaGrid(
|
|||||||
mediaItems: List<MediaItem>,
|
mediaItems: List<MediaItem>,
|
||||||
selectedItemOrder: List<Long>,
|
selectedItemOrder: List<Long>,
|
||||||
showCameraItem: Boolean = true,
|
showCameraItem: Boolean = true,
|
||||||
|
cameraEnabled: Boolean = true,
|
||||||
gridState: LazyGridState = rememberLazyGridState(),
|
gridState: LazyGridState = rememberLazyGridState(),
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||||
@@ -87,6 +88,7 @@ internal fun MediaGrid(
|
|||||||
item(key = "camera_button") {
|
item(key = "camera_button") {
|
||||||
CameraGridItem(
|
CameraGridItem(
|
||||||
onClick = onCameraClick,
|
onClick = onCameraClick,
|
||||||
|
enabled = cameraEnabled,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,7 @@ internal fun MediaGrid(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun CameraGridItem(
|
internal fun CameraGridItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -147,7 +150,9 @@ internal fun CameraGridItem(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
val enabledState = rememberUpdatedState(enabled)
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val provider = cameraProvider
|
val provider = cameraProvider
|
||||||
val preview = previewUseCase
|
val preview = previewUseCase
|
||||||
@@ -167,10 +172,10 @@ internal fun CameraGridItem(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = onClick),
|
.clickable(enabled = enabled, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (hasCameraPermission) {
|
if (hasCameraPermission && enabled) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
val previewView = PreviewView(ctx).apply {
|
val previewView = PreviewView(ctx).apply {
|
||||||
@@ -181,6 +186,9 @@ internal fun CameraGridItem(
|
|||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
|
if (!enabledState.value) {
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
val provider = cameraProviderFuture.get()
|
val provider = cameraProviderFuture.get()
|
||||||
cameraProvider = provider
|
cameraProvider = provider
|
||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
|
|||||||
internal fun AttachAlertPhotoLayout(
|
internal fun AttachAlertPhotoLayout(
|
||||||
state: AttachAlertUiState,
|
state: AttachAlertUiState,
|
||||||
gridState: LazyGridState,
|
gridState: LazyGridState,
|
||||||
|
cameraEnabled: Boolean = true,
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||||
onItemCheckClick: (MediaItem) -> Unit,
|
onItemCheckClick: (MediaItem) -> Unit,
|
||||||
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
|
|||||||
mediaItems = state.visibleMediaItems,
|
mediaItems = state.visibleMediaItems,
|
||||||
selectedItemOrder = state.selectedItemOrder,
|
selectedItemOrder = state.selectedItemOrder,
|
||||||
showCameraItem = state.visibleAlbum?.isAllMedia != false,
|
showCameraItem = state.visibleAlbum?.isAllMedia != false,
|
||||||
|
cameraEnabled = cameraEnabled,
|
||||||
gridState = gridState,
|
gridState = gridState,
|
||||||
onCameraClick = onCameraClick,
|
onCameraClick = onCameraClick,
|
||||||
onItemClick = onItemClick,
|
onItemClick = onItemClick,
|
||||||
|
|||||||
@@ -122,13 +122,6 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class PickerSystemBarsSnapshot(
|
|
||||||
val scrimAlpha: Float,
|
|
||||||
val isFullScreen: Boolean,
|
|
||||||
val isDarkTheme: Boolean,
|
|
||||||
val openProgress: Float
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telegram-style attach alert (media picker bottom sheet).
|
* Telegram-style attach alert (media picker bottom sheet).
|
||||||
*
|
*
|
||||||
@@ -741,51 +734,48 @@ fun ChatAttachAlert(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(shouldShow, state.editingItem) {
|
LaunchedEffect(
|
||||||
|
shouldShow,
|
||||||
|
state.editingItem,
|
||||||
|
isPickerFullScreen,
|
||||||
|
isDarkTheme,
|
||||||
|
hasNativeNavigationBar
|
||||||
|
) {
|
||||||
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
||||||
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
||||||
snapshotFlow {
|
val dark = isDarkTheme
|
||||||
PickerSystemBarsSnapshot(
|
val fullScreen = isPickerFullScreen
|
||||||
scrimAlpha = scrimAlpha,
|
|
||||||
isFullScreen = isPickerFullScreen,
|
|
||||||
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) {
|
if (hasNativeNavigationBar) {
|
||||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
|
||||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
window.navigationBarColor = navBaseColor
|
||||||
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = true
|
window.isNavigationBarContrastEnforced = true
|
||||||
}
|
}
|
||||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||||
} else {
|
} else {
|
||||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
|
||||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = false
|
window.isNavigationBarContrastEnforced = false
|
||||||
}
|
}
|
||||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram-like natural dim: status-bar tint follows the same scrim alpha
|
||||||
|
// as the popup overlay, so top area and content overlay always match.
|
||||||
|
if (fullScreen) {
|
||||||
|
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
|
insetsController?.isAppearanceLightStatusBars = !dark
|
||||||
|
} else {
|
||||||
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
|
var lastAppliedAlpha = -1
|
||||||
|
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
|
||||||
|
.collect { alpha ->
|
||||||
|
if (alpha != lastAppliedAlpha) {
|
||||||
|
lastAppliedAlpha = alpha
|
||||||
|
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,6 +1052,7 @@ fun ChatAttachAlert(
|
|||||||
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
|
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
|
||||||
state = state,
|
state = state,
|
||||||
gridState = mediaGridState,
|
gridState = mediaGridState,
|
||||||
|
cameraEnabled = !isClosing,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
requestClose {
|
requestClose {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
|
|||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.boundsInWindow
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -333,6 +341,105 @@ enum class DownloadStatus {
|
|||||||
ERROR
|
ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class TelegramFileActionState {
|
||||||
|
FILE,
|
||||||
|
DOWNLOAD,
|
||||||
|
CANCEL,
|
||||||
|
PAUSE,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TelegramFileActionButton(
|
||||||
|
state: TelegramFileActionState,
|
||||||
|
progress: Float?,
|
||||||
|
indeterminate: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val spinTransition = rememberInfiniteTransition(label = "file_action_spin")
|
||||||
|
val spin by spinTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "file_action_spin_progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
val backgroundColor = if (state == TelegramFileActionState.ERROR) Color(0xFFE53935) else PrimaryBlue
|
||||||
|
val iconPainter = when (state) {
|
||||||
|
TelegramFileActionState.FILE -> TelegramIcons.File
|
||||||
|
TelegramFileActionState.DOWNLOAD -> painterResource(R.drawable.msg_download)
|
||||||
|
TelegramFileActionState.CANCEL -> TelegramIcons.Close
|
||||||
|
TelegramFileActionState.ERROR -> TelegramIcons.Close
|
||||||
|
TelegramFileActionState.PAUSE -> null
|
||||||
|
}
|
||||||
|
val iconSize =
|
||||||
|
when (state) {
|
||||||
|
TelegramFileActionState.ERROR -> 18.dp
|
||||||
|
TelegramFileActionState.PAUSE -> 18.dp
|
||||||
|
else -> 20.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
val showProgressRing =
|
||||||
|
(state == TelegramFileActionState.PAUSE ||
|
||||||
|
state == TelegramFileActionState.DOWNLOAD ||
|
||||||
|
state == TelegramFileActionState.CANCEL) &&
|
||||||
|
(indeterminate || progress != null)
|
||||||
|
val sweep = when {
|
||||||
|
indeterminate -> 104f
|
||||||
|
progress != null -> (progress.coerceIn(0f, 1f) * 360f)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
val startAngle = if (indeterminate) (spin * 360f) - 90f else -90f
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.size(40.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(backgroundColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (showProgressRing && sweep > 0f) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val strokeWidth = 2.dp.toPx()
|
||||||
|
val inset = 3.dp.toPx()
|
||||||
|
drawArc(
|
||||||
|
color = Color.White,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = sweep,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(inset, inset),
|
||||||
|
size = Size(size.width - inset * 2f, size.height - inset * 2f),
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == TelegramFileActionState.PAUSE) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Pause,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(iconSize)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = iconPainter ?: TelegramIcons.File,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(iconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
|
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
|
||||||
* коллаж (как в Telegram)
|
* коллаж (как в Telegram)
|
||||||
@@ -1454,6 +1561,8 @@ fun FileAttachment(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||||
var downloadProgress by remember { mutableStateOf(0f) }
|
var downloadProgress by remember { mutableStateOf(0f) }
|
||||||
|
var isPaused by remember { mutableStateOf(false) }
|
||||||
|
var lastActionAtMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
// Bounce animation for icon
|
// Bounce animation for icon
|
||||||
val iconScale = remember { Animatable(0f) }
|
val iconScale = remember { Animatable(0f) }
|
||||||
@@ -1495,16 +1604,40 @@ fun FileAttachment(
|
|||||||
downloadStatus = when (state.status) {
|
downloadStatus = when (state.status) {
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> DownloadStatus.DOWNLOADING
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||||
}
|
}
|
||||||
|
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
|
val existingState = com.rosetta.messenger.network.FileDownloadManager.stateOf(attachment.id)
|
||||||
|
if (existingState != null) {
|
||||||
|
downloadProgress = existingState.progress
|
||||||
|
downloadStatus = when (existingState.status) {
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING ->
|
||||||
|
DownloadStatus.DOWNLOADING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.PAUSED ->
|
||||||
|
DownloadStatus.DOWNLOADING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING ->
|
||||||
|
DownloadStatus.DECRYPTING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DONE ->
|
||||||
|
DownloadStatus.DOWNLOADED
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.ERROR ->
|
||||||
|
DownloadStatus.ERROR
|
||||||
|
}
|
||||||
|
isPaused =
|
||||||
|
existingState.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||||
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
isPaused = false
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
downloadStatus = if (isDownloadTag(preview)) {
|
downloadStatus = if (isDownloadTag(preview)) {
|
||||||
@@ -1516,6 +1649,7 @@ fun FileAttachment(
|
|||||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
||||||
}
|
}
|
||||||
|
isPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Открыть файл через системное приложение
|
// Открыть файл через системное приложение
|
||||||
@@ -1551,10 +1685,8 @@ fun FileAttachment(
|
|||||||
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||||
val download: () -> Unit = {
|
val download: () -> Unit = {
|
||||||
if (downloadTag.isNotEmpty()) {
|
if (downloadTag.isNotEmpty()) {
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
isPaused = false
|
||||||
downloadProgress = 0f
|
|
||||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||||
context = context,
|
|
||||||
attachmentId = attachment.id,
|
attachmentId = attachment.id,
|
||||||
downloadTag = downloadTag,
|
downloadTag = downloadTag,
|
||||||
chachaKey = chachaKey,
|
chachaKey = chachaKey,
|
||||||
@@ -1566,19 +1698,56 @@ fun FileAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pauseDownload: () -> Unit = {
|
||||||
|
com.rosetta.messenger.network.FileDownloadManager.pause(attachment.id)
|
||||||
|
isPaused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val resumeDownload: () -> Unit = {
|
||||||
|
isPaused = false
|
||||||
|
com.rosetta.messenger.network.FileDownloadManager.resume(attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isSendingUpload = isOutgoing && messageStatus == MessageStatus.SENDING
|
||||||
|
val isDownloadInProgress =
|
||||||
|
!isPaused &&
|
||||||
|
(downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||||
|
downloadStatus == DownloadStatus.DECRYPTING)
|
||||||
|
val actionState = when {
|
||||||
|
downloadStatus == DownloadStatus.ERROR -> TelegramFileActionState.ERROR
|
||||||
|
isSendingUpload -> TelegramFileActionState.CANCEL
|
||||||
|
isDownloadInProgress -> TelegramFileActionState.PAUSE
|
||||||
|
isPaused -> TelegramFileActionState.DOWNLOAD
|
||||||
|
downloadStatus == DownloadStatus.NOT_DOWNLOADED -> TelegramFileActionState.DOWNLOAD
|
||||||
|
else -> TelegramFileActionState.FILE
|
||||||
|
}
|
||||||
|
val actionProgress = if (isDownloadInProgress || isPaused) animatedProgress else null
|
||||||
|
|
||||||
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.clickable(
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
enabled =
|
enabled =
|
||||||
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
!isSendingUpload &&
|
||||||
|
(downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
|
||||||
downloadStatus == DownloadStatus.DOWNLOADED ||
|
downloadStatus == DownloadStatus.DOWNLOADED ||
|
||||||
downloadStatus == DownloadStatus.ERROR
|
downloadStatus == DownloadStatus.ERROR ||
|
||||||
|
isDownloadInProgress ||
|
||||||
|
isPaused)
|
||||||
) {
|
) {
|
||||||
when (downloadStatus) {
|
val now = SystemClock.elapsedRealtime()
|
||||||
DownloadStatus.DOWNLOADED -> openFile()
|
if (now - lastActionAtMs < 220L) return@clickable
|
||||||
|
lastActionAtMs = now
|
||||||
|
|
||||||
|
when {
|
||||||
|
isPaused -> resumeDownload()
|
||||||
|
downloadStatus == DownloadStatus.DOWNLOADING ||
|
||||||
|
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
|
||||||
|
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
|
||||||
else -> download()
|
else -> download()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1595,63 +1764,13 @@ fun FileAttachment(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Круглый фон иконки
|
TelegramFileActionButton(
|
||||||
Box(
|
state = actionState,
|
||||||
modifier =
|
progress = actionProgress,
|
||||||
Modifier.fillMaxSize()
|
indeterminate = isSendingUpload,
|
||||||
.clip(CircleShape)
|
modifier = Modifier.fillMaxSize()
|
||||||
.background(
|
|
||||||
if (downloadStatus == DownloadStatus.ERROR)
|
|
||||||
Color(0xFFE53935)
|
|
||||||
else PrimaryBlue
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
when (downloadStatus) {
|
|
||||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
|
||||||
// Determinate progress like Telegram
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = downloadProgress.coerceIn(0f, 1f),
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DownloadStatus.NOT_DOWNLOADED -> {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowDownward,
|
|
||||||
contentDescription = "Download",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DownloadStatus.DOWNLOADED -> {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.InsertDriveFile,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DownloadStatus.ERROR -> {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Error",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.InsertDriveFile,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
|
||||||
@@ -1679,8 +1798,24 @@ fun FileAttachment(
|
|||||||
PrimaryBlue
|
PrimaryBlue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSendingUpload) {
|
||||||
|
AnimatedDotsText(
|
||||||
|
baseText = "Uploading",
|
||||||
|
color = statusColor,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING -> {
|
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"
|
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||||
// CDN download maps to progress 0..0.8
|
// CDN download maps to progress 0..0.8
|
||||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||||
@@ -1691,6 +1826,7 @@ fun FileAttachment(
|
|||||||
color = statusColor
|
color = statusColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DownloadStatus.DECRYPTING -> {
|
DownloadStatus.DECRYPTING -> {
|
||||||
AnimatedDotsText(
|
AnimatedDotsText(
|
||||||
baseText = "Decrypting",
|
baseText = "Decrypting",
|
||||||
@@ -1711,6 +1847,7 @@ fun FileAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Time and checkmarks (bottom-right overlay) for outgoing files
|
// Time and checkmarks (bottom-right overlay) for outgoing files
|
||||||
if (isOutgoing) {
|
if (isOutgoing) {
|
||||||
|
|||||||
@@ -858,8 +858,14 @@ fun MessageBubble(
|
|||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
val senderLabelText =
|
||||||
|
senderName
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.trim()
|
||||||
|
val senderLabelMaxWidth =
|
||||||
|
if (isGroupSenderAdmin) 170.dp else 220.dp
|
||||||
Text(
|
Text(
|
||||||
text = senderName,
|
text = senderLabelText,
|
||||||
color =
|
color =
|
||||||
groupSenderLabelColor(
|
groupSenderLabelColor(
|
||||||
senderPublicKey,
|
senderPublicKey,
|
||||||
@@ -867,6 +873,7 @@ fun MessageBubble(
|
|||||||
),
|
),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,13 +82,6 @@ import kotlin.math.roundToInt
|
|||||||
private const val TAG = "MediaPickerBottomSheet"
|
private const val TAG = "MediaPickerBottomSheet"
|
||||||
private const val ALL_MEDIA_ALBUM_ID = 0L
|
private const val ALL_MEDIA_ALBUM_ID = 0L
|
||||||
|
|
||||||
private data class PickerSystemBarsSnapshot(
|
|
||||||
val scrimAlpha: Float,
|
|
||||||
val isFullScreen: Boolean,
|
|
||||||
val isDarkTheme: Boolean,
|
|
||||||
val openProgress: Float
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media item from gallery
|
* Media item from gallery
|
||||||
*/
|
*/
|
||||||
@@ -606,55 +599,49 @@ fun MediaPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive updates — single snapshotFlow drives ALL system bar colors
|
// Telegram-style: system bar updates only by picker state,
|
||||||
LaunchedEffect(shouldShow, editingItem) {
|
// no per-frame status bar color animation.
|
||||||
|
LaunchedEffect(
|
||||||
|
shouldShow,
|
||||||
|
editingItem,
|
||||||
|
isPickerFullScreen,
|
||||||
|
isDarkTheme,
|
||||||
|
hasNativeNavigationBar
|
||||||
|
) {
|
||||||
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
||||||
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
||||||
|
val dark = isDarkTheme
|
||||||
|
val fullScreen = isPickerFullScreen
|
||||||
|
|
||||||
snapshotFlow {
|
|
||||||
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) {
|
if (hasNativeNavigationBar) {
|
||||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
|
||||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
window.navigationBarColor = navBaseColor
|
||||||
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = true
|
window.isNavigationBarContrastEnforced = true
|
||||||
}
|
}
|
||||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||||
} else {
|
} else {
|
||||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
|
||||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = false
|
window.isNavigationBarContrastEnforced = false
|
||||||
}
|
}
|
||||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1034,7 @@ fun MediaPickerBottomSheet(
|
|||||||
mediaItems = visibleMediaItems,
|
mediaItems = visibleMediaItems,
|
||||||
selectedItemOrder = selectedItemOrder,
|
selectedItemOrder = selectedItemOrder,
|
||||||
showCameraItem = selectedAlbum?.isAllMedia != false,
|
showCameraItem = selectedAlbum?.isAllMedia != false,
|
||||||
|
cameraEnabled = !isClosing,
|
||||||
gridState = mediaGridState,
|
gridState = mediaGridState,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
requestClose {
|
requestClose {
|
||||||
@@ -1659,6 +1647,7 @@ private fun MediaGrid(
|
|||||||
mediaItems: List<MediaItem>,
|
mediaItems: List<MediaItem>,
|
||||||
selectedItemOrder: List<Long>,
|
selectedItemOrder: List<Long>,
|
||||||
showCameraItem: Boolean = true,
|
showCameraItem: Boolean = true,
|
||||||
|
cameraEnabled: Boolean = true,
|
||||||
gridState: LazyGridState = rememberLazyGridState(),
|
gridState: LazyGridState = rememberLazyGridState(),
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
|
||||||
@@ -1685,6 +1674,7 @@ private fun MediaGrid(
|
|||||||
item(key = "camera_button") {
|
item(key = "camera_button") {
|
||||||
CameraGridItem(
|
CameraGridItem(
|
||||||
onClick = onCameraClick,
|
onClick = onCameraClick,
|
||||||
|
enabled = cameraEnabled,
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1715,6 +1705,7 @@ private fun MediaGrid(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CameraGridItem(
|
private fun CameraGridItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -1753,7 +1744,9 @@ private fun CameraGridItem(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
val enabledState = rememberUpdatedState(enabled)
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val provider = cameraProvider
|
val provider = cameraProvider
|
||||||
val preview = previewUseCase
|
val preview = previewUseCase
|
||||||
@@ -1773,10 +1766,10 @@ private fun CameraGridItem(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = onClick),
|
.clickable(enabled = enabled, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (hasCameraPermission) {
|
if (hasCameraPermission && enabled) {
|
||||||
// Show live camera preview
|
// Show live camera preview
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
@@ -1788,6 +1781,9 @@ private fun CameraGridItem(
|
|||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
|
if (!enabledState.value) {
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
val provider = cameraProviderFuture.get()
|
val provider = cameraProviderFuture.get()
|
||||||
cameraProvider = provider
|
cameraProvider = provider
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user