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
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
- Приведён к нормальному размер индикатор ошибки в чат-листе
Медиапикер и камера
- Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры
- Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения
- Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss
Файлы и загрузки
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
- Обновлён экран активных загрузок: добавлен статус Paused
Групповые сообщения
- Добавлен truncate для длинных имён отправителей в групповых пузырьках
- Убраны переносы в имени отправителя в шапке группового сообщения
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -1,6 +1,5 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import kotlinx.coroutines.*
@@ -20,6 +19,7 @@ data class FileDownloadState(
enum class FileDownloadStatus {
QUEUED,
DOWNLOADING,
PAUSED,
DECRYPTING,
DONE,
ERROR
@@ -35,6 +35,22 @@ object FileDownloadManager {
/** Текущие Job'ы — чтобы не запускать повторно */
private val jobs = mutableMapOf<String, Job>()
/** Последние параметры скачивания — нужны для resume */
private val requests = mutableMapOf<String, DownloadRequest>()
/** Флаг, что cancel произошёл именно как user pause */
private val pauseRequested = mutableSetOf<String>()
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
private val resumeAfterPause = mutableSetOf<String>()
private data class DownloadRequest(
val attachmentId: String,
val downloadTag: String,
val chachaKey: String,
val privateKey: String,
val accountPublicKey: String,
val fileName: String,
val savedFile: File
)
// ─── helpers ───
@@ -67,9 +83,16 @@ object FileDownloadManager {
*/
fun isDownloading(attachmentId: String): Boolean {
val state = _downloads.value[attachmentId] ?: return false
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
return state.status == FileDownloadStatus.QUEUED ||
state.status == FileDownloadStatus.DOWNLOADING ||
state.status == FileDownloadStatus.DECRYPTING
}
fun isPaused(attachmentId: String): Boolean =
_downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED
fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId]
/**
* Возвращает Flow<FileDownloadState?> для конкретного attachment
*/
@@ -81,7 +104,6 @@ object FileDownloadManager {
* Скачивание продолжается даже если пользователь вышел из чата.
*/
fun download(
context: Context,
attachmentId: String,
downloadTag: String,
chachaKey: String,
@@ -90,132 +112,232 @@ object FileDownloadManager {
fileName: String,
savedFile: File
) {
// Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return
val normalizedAccount = accountPublicKey.trim()
val savedPath = savedFile.absolutePath
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(
val request = DownloadRequest(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
accountPublicKey = accountPublicKey.trim(),
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
savedFile = savedFile
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
)
}
requests[attachmentId] = request
startDownload(request)
}
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(
attachmentId,
fileName,
FileDownloadStatus.DONE,
1f,
normalizedAccount,
savedPath
)
} else {
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
id = attachmentId,
fileName = current.fileName,
status = FileDownloadStatus.PAUSED,
progress = pausedProgress,
accountPublicKey = current.accountPublicKey,
savedPath = current.savedPath
)
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} finally {
jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения
scope.launch {
delay(5000)
_downloads.update { it - attachmentId }
}
}
fun resume(attachmentId: String) {
val request = requests[attachmentId] ?: return
if (jobs[attachmentId]?.isActive == true) {
resumeAfterPause.add(attachmentId)
return
}
pauseRequested.remove(attachmentId)
startDownload(request)
}
/**
* Отменяет скачивание
*/
fun cancel(attachmentId: String) {
pauseRequested.remove(attachmentId)
resumeAfterPause.remove(attachmentId)
requests.remove(attachmentId)
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel()
jobs.remove(attachmentId)
_downloads.update { it - attachmentId }
}
private fun startDownload(request: DownloadRequest) {
val attachmentId = request.attachmentId
if (jobs[attachmentId]?.isActive == true) return
pauseRequested.remove(attachmentId)
val savedPath = request.savedFile.absolutePath
val resumeBase =
(_downloads.value[attachmentId]
?.takeIf { it.status == FileDownloadStatus.PAUSED }
?.progress
?: 0f).coerceIn(0f, 0.8f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.QUEUED,
resumeBase,
request.accountPublicKey,
savedPath
)
jobs[attachmentId] = scope.launch {
var progressJob: Job? = null
try {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DOWNLOADING,
resumeBase,
request.accountPublicKey,
savedPath
)
// Запускаем polling прогресса из TransportManager.
// При resume удерживаем плавный прогресс без визуального отката назад.
progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId } ?: return@collect
val rawCdn = (entry.progress / 100f) * 0.8f
val mapped = if (resumeBase > 0f) {
val normalized = (rawCdn / 0.8f).coerceIn(0f, 1f)
resumeBase + (0.8f - resumeBase) * normalized
} else {
rawCdn
}
val current = _downloads.value[attachmentId]?.progress ?: 0f
val stable = maxOf(current, mapped).coerceIn(0f, 0.8f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.DOWNLOADING,
stable,
request.accountPublicKey,
savedPath
)
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(request.chachaKey)) {
downloadGroupFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
}
}
if (success) {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DONE,
1f,
request.accountPublicKey,
savedPath
)
} else {
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
}
} catch (e: CancellationException) {
if (pauseRequested.remove(attachmentId)) {
val current = _downloads.value[attachmentId]
val pausedProgress = (current?.progress ?: resumeBase).coerceIn(0f, 0.98f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.PAUSED,
pausedProgress,
request.accountPublicKey,
savedPath
)
} else {
throw e
}
} catch (e: Exception) {
e.printStackTrace()
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
} finally {
progressJob?.cancel()
jobs.remove(attachmentId)
if (resumeAfterPause.remove(attachmentId)) {
scope.launch { startDownload(request) }
}
// Автоочистка только терминальных состояний.
val terminalStatus = _downloads.value[attachmentId]?.status
if (
terminalStatus == FileDownloadStatus.DONE ||
terminalStatus == FileDownloadStatus.ERROR
) {
scope.launch {
delay(5000)
val current = _downloads.value[attachmentId]
if (
current?.status == FileDownloadStatus.DONE ||
current?.status == FileDownloadStatus.ERROR
) {
_downloads.update { it - attachmentId }
}
}
}
}
}
}
// ─── internal download logic (moved from FileAttachment) ───
private suspend fun downloadGroupFile(
@@ -339,12 +461,19 @@ object FileDownloadManager {
savedPath: String
) {
_downloads.update { map ->
val previous = map[id]
val normalizedProgress =
when (status) {
FileDownloadStatus.DONE -> 1f
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
}
map + (
id to FileDownloadState(
attachmentId = id,
fileName = fileName,
status = status,
progress = progress,
progress = normalizedProgress,
accountPublicKey = accountPublicKey,
savedPath = savedPath
)

View File

@@ -1,16 +1,19 @@
package com.rosetta.messenger.network
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -41,6 +44,7 @@ object TransportManager {
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
@@ -93,6 +97,8 @@ object TransportManager {
repeat(MAX_RETRIES) { attempt ->
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
if (attempt < MAX_RETRIES - 1) {
@@ -111,6 +117,46 @@ object TransportManager {
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 файла
@@ -226,17 +272,7 @@ object TransportManager {
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
val response = awaitDownloadResponse(id, request)
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
@@ -310,6 +346,7 @@ object TransportManager {
)
throw e
} finally {
activeDownloadCalls.remove(id)?.cancel()
// Удаляем из списка скачиваний
_downloading.value = _downloading.value.filter { it.id != id }
}
@@ -350,16 +387,7 @@ object TransportManager {
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
val response = awaitDownloadResponse(id, request)
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
@@ -416,6 +444,7 @@ object TransportManager {
)
throw e
} finally {
activeDownloadCalls.remove(id)?.cancel()
_downloading.value = _downloading.value.filter { it.id != id }
}
}

View File

@@ -348,13 +348,17 @@ fun ChatDetailScreen(
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
// 🎨 Управление статус баром.
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
if (!view.isInEditMode) {
SideEffect {
if (showImageViewer) {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
} else {
if (window != null && view != null) {
val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false

View File

@@ -483,6 +483,9 @@ fun ChatsListScreen(
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DOWNLOADING ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.PAUSED ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DECRYPTING
@@ -5057,6 +5060,7 @@ private fun formatDownloadStatusText(
return when (item.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> "Paused $percent%"
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"

View File

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

View File

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

View File

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

View File

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

View File

@@ -858,8 +858,14 @@ fun MessageBubble(
),
verticalAlignment = Alignment.CenterVertically
) {
val senderLabelText =
senderName
.replace('\n', ' ')
.trim()
val senderLabelMaxWidth =
if (isGroupSenderAdmin) 170.dp else 220.dp
Text(
text = senderName,
text = senderLabelText,
color =
groupSenderLabelColor(
senderPublicKey,
@@ -867,6 +873,7 @@ fun MessageBubble(
),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View File

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