v1.2.4: фиксы медиапикера, файловых загрузок и UI групп

This commit is contained in:
2026-03-20 14:29:12 +05:00
parent 0353f845a5
commit 4440016d5f
11 changed files with 661 additions and 339 deletions

View File

@@ -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 =

View File

@@ -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
) )

View File

@@ -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 }
} }
} }

View File

@@ -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

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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,

View File

@@ -122,13 +122,6 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
} }
} }
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/** /**
* Telegram-style attach alert (media picker bottom sheet). * 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()

View File

@@ -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) {

View File

@@ -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
) )

View File

@@ -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