Compare commits

...

19 Commits

Author SHA1 Message Date
eccaf018cf Фикс кривой иконки на экране групп
All checks were successful
Android Kernel Build / build (push) Successful in 16h10m37s
2026-03-04 17:13:33 +05:00
9e5e81d5e5 Фикс прыгающей галочки 2026-03-04 15:43:07 +05:00
62857da793 feat: enhance keyboard height management and emoji picker synchronization in GroupSetupScreen 2026-03-04 14:34:17 +05:00
d1aca8439a feat: update message status handling for error cases in ChatViewModel and UI components 2026-03-04 01:00:08 +05:00
2501296d70 feat: enhance swipe-back functionality in OtherProfileScreen and SwipeBackContainer 2026-03-03 20:47:32 +05:00
ddb6207bb5 feat: implement HorizontalPager for tab navigation in OtherProfileScreen 2026-03-03 19:07:58 +05:00
d0fc8f2f1a feat: implement fullscreen image viewer in SearchScreen and refactor MediaTabContent 2026-03-03 03:28:12 +05:00
86d42c8e10 feat: update version to 1.1.2 and enhance search, group info, UI and media features
Search Screen:
- Complete Search screen with 4 tabs: Chats, Media, Downloads, Files
- Media grid with real image loading from AttachmentFileManager + CDN fallback
- Fullscreen image viewer with swipe navigation from Media tab
- Downloads tab showing files from rosetta_downloads directory
- Files tab showing file attachments from all chats
- Links tab with URL extraction from messages

Group Features:
- Group invite cards redesigned inside chat bubbles with action button
- In-memory cache for group invite info (no loading on re-enter)
- Group members caching with TTL for faster group info loading
- Encryption key screen redesigned (Telegram-style 12x12 identicon)
- Fixed group members online status using PacketOnlineSubscribe/PacketOnlineState
- Emoji picker integration in GroupSetupScreen with proper keyboard handling

Chat & UI:
- Improved message delivery status handling and indicators
- Enhanced camera handling and UI state management
- File download manager with progress tracking
- Download indicator component
- File opening support in OtherProfileScreen (local, downloaded, blob)
- SwipeBack container improvements
- Theme screen and wallpaper selection enhancements
- FAB positioning fixes for keyboard/emoji panel in GroupSetupScreen
2026-03-03 03:13:43 +05:00
91a47892f2 feat: update version to 1.1.2 and enhance search, group info, UI and media features
Search Screen:
- Complete Search screen with 4 tabs: Chats, Media, Downloads, Files
- Media grid with real image loading from AttachmentFileManager + CDN fallback
- Fullscreen image viewer with swipe navigation from Media tab
- Downloads tab showing files from rosetta_downloads directory
- Files tab showing file attachments from all chats
- Links tab with URL extraction from messages

Group Features:
- Group invite cards redesigned inside chat bubbles with action button
- In-memory cache for group invite info (no loading on re-enter)
- Group members caching with TTL for faster group info loading
- Encryption key screen redesigned (Telegram-style 12x12 identicon)
- Fixed group members online status using PacketOnlineSubscribe/PacketOnlineState
- Emoji picker integration in GroupSetupScreen with proper keyboard handling

Chat & UI:
- Improved message delivery status handling and indicators
- Enhanced camera handling and UI state management
- File download manager with progress tracking
- Download indicator component
- File opening support in OtherProfileScreen (local, downloaded, blob)
- SwipeBack container improvements
- Theme screen and wallpaper selection enhancements
- FAB positioning fixes for keyboard/emoji panel in GroupSetupScreen
2026-03-03 03:09:22 +05:00
c53cb87595 feat: implement caching for group invite info and enhance message bubble layout for group invites 2026-03-03 03:06:11 +05:00
36fb8609d5 feat: adjust FAB bottom padding logic in GroupSetupScreen for improved keyboard and emoji panel handling; update font size in SearchScreen 2026-03-03 00:09:41 +05:00
50b27fcbb3 feat: update FAB bottom padding logic in GroupSetupScreen to improve positioning with keyboard and emoji panel 2026-03-03 00:02:27 +05:00
9ddad8ec3c feat: update FAB positioning logic in GroupSetupScreen to account for emoji panel and keyboard visibility 2026-03-03 00:02:09 +05:00
3f3dd956cb feat: enhance file opening logic in OtherProfileScreen to support local, downloaded, and blob files 2026-03-02 23:54:16 +05:00
8c7ac53506 feat: Enhance chat UI with group invite handling and new download indicator
- Added support for standalone group invites in MessageBubble component.
- Improved bubble padding and width handling for group invites.
- Refactored MessageBubble to streamline background and border logic.
- Introduced AnimatedDownloadIndicator for a more engaging download experience.
- Created ThemeWallpapers data structure to manage chat wallpapers.
- Implemented WallpaperSelectorRow and WallpaperSelectorItem for theme customization.
- Updated ThemeScreen to allow wallpaper selection and preview.
- Added new drawable resources for download and search icons.
2026-03-02 23:40:44 +05:00
16c48992a5 feat: implement group members caching and enhance emoji picker functionality in GroupSetupScreen 2026-03-02 15:31:38 +05:00
df8fbfc5d3 feat: add delay for focus request in GroupSetupScreen during details step 2026-03-02 14:19:59 +05:00
8f7544c655 feat: enhance camera handling and UI state management in chat components 2026-03-02 14:07:42 +05:00
6d379148b0 feat: improve message delivery status handling and UI indicators in chat screens 2026-03-02 11:35:03 +05:00
57 changed files with 3743 additions and 1054 deletions

View File

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

View File

@@ -54,7 +54,7 @@ class KeyboardTransitionCoordinator {
var keyboardHeight by mutableStateOf(0.dp) var keyboardHeight by mutableStateOf(0.dp)
var emojiHeight by mutableStateOf(0.dp) var emojiHeight by mutableStateOf(0.dp)
// 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji // Максимальная высота клавиатуры (защищает от промежуточных значений при анимации закрытия)
private var maxKeyboardHeight by mutableStateOf(0.dp) private var maxKeyboardHeight by mutableStateOf(0.dp)
// ============ Флаги видимости ============ // ============ Флаги видимости ============
@@ -84,8 +84,10 @@ class KeyboardTransitionCoordinator {
currentState = TransitionState.KEYBOARD_TO_EMOJI currentState = TransitionState.KEYBOARD_TO_EMOJI
isTransitioning = true isTransitioning = true
// 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры) // Устанавливаем emojiHeight = текущая высота клавиатуры (клавиатура ещё открыта)
if (maxKeyboardHeight > 0.dp) { if (keyboardHeight > 0.dp) {
emojiHeight = keyboardHeight
} else if (maxKeyboardHeight > 0.dp) {
emojiHeight = maxKeyboardHeight emojiHeight = maxKeyboardHeight
} }
@@ -234,7 +236,7 @@ class KeyboardTransitionCoordinator {
if (height > 100.dp && height != keyboardHeight) { if (height > 100.dp && height != keyboardHeight) {
keyboardHeight = height keyboardHeight = height
// 🔥 Сохраняем максимальную высоту // Обновляем maxKeyboardHeight только вверх (защита от промежуточных значений анимации)
if (height > maxKeyboardHeight) { if (height > maxKeyboardHeight) {
maxKeyboardHeight = height maxKeyboardHeight = height
} }
@@ -244,14 +246,14 @@ class KeyboardTransitionCoordinator {
emojiHeight = height emojiHeight = height
} }
} else if (height == 0.dp && keyboardHeight != 0.dp) { } else if (height == 0.dp && keyboardHeight != 0.dp) {
// 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты // Клавиатура закрывается.
// Если emoji уже показан (keyboard→emoji переход), НЕ трогаем emojiHeight —
// Восстанавливаем emojiHeight до максимальной высоты // requestShowEmoji() уже установил правильное значение = текущая высота клавиатуры.
if (maxKeyboardHeight > 0.dp) { // Восстанавливаем из maxKeyboardHeight только если emoji НЕ виден (обычное закрытие).
if (!isEmojiVisible && !isEmojiBoxVisible && maxKeyboardHeight > 0.dp) {
emojiHeight = maxKeyboardHeight emojiHeight = maxKeyboardHeight
} }
// Обнуляем keyboardHeight
keyboardHeight = 0.dp keyboardHeight = 0.dp
} }
@@ -272,7 +274,8 @@ class KeyboardTransitionCoordinator {
* emojiHeight должна оставаться фиксированной! * emojiHeight должна оставаться фиксированной!
*/ */
fun syncHeights() { fun syncHeights() {
// 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji // Синхронизируем только вверх — при закрытии клавиатуры промежуточные значения
// не должны уменьшать emojiHeight. Точная высота ставится в requestShowEmoji().
if (keyboardHeight > 100.dp && keyboardHeight > emojiHeight) { if (keyboardHeight > 100.dp && keyboardHeight > emojiHeight) {
emojiHeight = keyboardHeight emojiHeight = keyboardHeight
} }

View File

@@ -676,6 +676,7 @@ fun MainScreen(
prefsManager prefsManager
.backgroundBlurColorIdForAccount(accountPublicKey) .backgroundBlurColorIdForAccount(accountPublicKey)
.collectAsState(initial = "avatar") .collectAsState(initial = "avatar")
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet()) val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
// AvatarRepository для работы с аватарами // AvatarRepository для работы с аватарами
@@ -894,13 +895,18 @@ fun MainScreen(
isVisible = isThemeVisible, isVisible = isThemeVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 2 layer = 2,
deferToChildren = true
) { ) {
ThemeScreen( ThemeScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
currentThemeMode = themeMode, currentThemeMode = themeMode,
currentWallpaperId = chatWallpaperId,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
onThemeModeChange = onThemeModeChange onThemeModeChange = onThemeModeChange,
onWallpaperChange = { wallpaperId ->
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
}
) )
} }
@@ -989,6 +995,7 @@ fun MainScreen(
} + Screen.ChatDetail(forwardUser) } + Screen.ChatDetail(forwardUser)
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
chatWallpaperId = chatWallpaperId,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked } onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
) )
@@ -1072,6 +1079,9 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey, accountPrivateKey = accountPrivateKey,
accountName = accountName,
accountUsername = accountUsername,
avatarRepository = avatarRepository,
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } }, onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
onGroupOpened = { groupUser -> onGroupOpened = { groupUser ->
navStack = navStack =

View File

@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.PacketGroupLeave
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -27,6 +28,8 @@ class GroupRepository private constructor(context: Context) {
private val messageDao = db.messageDao() private val messageDao = db.messageDao()
private val dialogDao = db.dialogDao() private val dialogDao = db.dialogDao()
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
companion object { companion object {
private const val GROUP_PREFIX = "#group:" private const val GROUP_PREFIX = "#group:"
private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_INVITE_PASSWORD = "rosetta_group"
@@ -159,6 +162,20 @@ class GroupRepository private constructor(context: Context) {
return response.members return response.members
} }
fun getCachedInviteInfo(groupId: String): GroupInviteInfoResult? {
val normalized = normalizeGroupId(groupId)
return inviteInfoCache[normalized]
}
fun cacheInviteInfo(groupId: String, status: GroupStatus, membersCount: Int) {
val normalized = normalizeGroupId(groupId)
inviteInfoCache[normalized] = GroupInviteInfoResult(
groupId = normalized,
membersCount = membersCount,
status = status
)
}
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? { suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
val groupId = normalizeGroupId(groupPublicKeyOrId) val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null if (groupId.isBlank()) return null
@@ -176,11 +193,13 @@ class GroupRepository private constructor(context: Context) {
) { incoming -> normalizeGroupId(incoming.groupId) == groupId } ) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
?: return null ?: return null
return GroupInviteInfoResult( val result = GroupInviteInfoResult(
groupId = groupId, groupId = groupId,
membersCount = response.membersCount.coerceAtLeast(0), membersCount = response.membersCount.coerceAtLeast(0),
status = response.groupStatus status = response.groupStatus
) )
inviteInfoCache[groupId] = result
return result
} }
suspend fun createGroup( suspend fun createGroup(

View File

@@ -27,6 +27,7 @@ class PreferencesManager(private val context: Context) {
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding") val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme") val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto" val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
// Notifications // Notifications
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled") val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
@@ -100,6 +101,13 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
} }
val chatWallpaperId: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
suspend fun setChatWallpaperId(value: String) {
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
}
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS // 🔔 NOTIFICATIONS
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

@@ -17,28 +17,27 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Группы и интерфейс Поиск
- Полностью обновлен экран группы в стиле приложения (по паритету с desktop логикой) - Новый полноценный экран поиска с вкладками: Чаты, Медиа, Загрузки, Файлы
- В участниках добавлены верификации, админ-метка и тултип администратора - Медиа-сетка с загрузкой реальных фотографий из истории чатов
- Добавлен просмотр Encryption Key с QR-кодом - Просмотр фото на весь экран со свайпом между ними из вкладки Медиа
- Улучшены секции Media/Files/Links: корректные пустые состояния и выравнивание медиа-сетки - Вкладка Загрузки — скачанные файлы
- Вкладка Файлы — файловые вложения из всех чатов
Сообщения и списки Группы
- Group Invite теперь отображается как invite-карточка вместо хэша (в чате и в chat list) - Приглашения в группу теперь отображаются внутри пузыря сообщения с кнопкой действия
- Для групп в chat list показывается иконка и автор последнего сообщения (You/имя отправителя) - Кэширование информации о приглашениях (больше нет загрузки при повторном открытии)
- Исправлено выравнивание превью вида "You: Photo" - Кэширование участников группы для быстрого открытия списка
- Системные события группы (например joined the group) приведены к desktop-стилю - Экран ключа шифрования переработан в стиле Telegram (12×12 identicon)
- Исправлен онлайн-статус участников — теперь онлайн только те кто реально онлайн
- Emoji-клавиатура в экране создания группы
Модерация групп Чат и интерфейс
- Добавлены свайп и long-press действия по участникам (Kick) - Улучшены индикаторы доставки сообщений
- Улучшены цвета, haptic и размеры action-кнопки; исправлен конфликт свайпа item vs экран - Менеджер загрузки файлов с отображением прогресса
- Для групп в chat list добавлены swipe-actions: Pin, Leave, Delete - Открытие файлов в профиле пользователя (локальные, загруженные, из blob)
- Новые обои для чатов
Синхронизация и стабильность - Улучшена работа камеры и управление состоянием UI
- Исправлены пропуски сообщений при массовой синхронизации личных и групповых чатов
- Sync теперь не продвигает курсор батча при ошибках обработки и делает безопасные ретраи
- Исправлены кейсы, где requests зависели от состояния устройства, а не аккаунта
- Rosetta Updates и Safe исключены из requests
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -490,6 +490,42 @@ interface MessageDao {
""" """
) )
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long) suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
// ═══════════════════════════════════════════════════════════
// 🔍 SEARCH: Media, Files
// ═══════════════════════════════════════════════════════════
/**
* Получить сообщения с IMAGE вложениями (type: 0)
* Для вкладки "Media" в поиске
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":0%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithMedia(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* Получить сообщения с FILE вложениями (type: 2)
* Для вкладки "Files" в поиске
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":2%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
} }
/** DAO для работы с диалогами */ /** DAO для работы с диалогами */

View File

@@ -0,0 +1,220 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.File
data class FileDownloadState(
val attachmentId: String,
val fileName: String,
val status: FileDownloadStatus,
/** 0f..1f */
val progress: Float = 0f
)
enum class FileDownloadStatus {
QUEUED,
DOWNLOADING,
DECRYPTING,
DONE,
ERROR
}
object FileDownloadManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/** Все активные/завершённые скачивания */
private val _downloads = MutableStateFlow<Map<String, FileDownloadState>>(emptyMap())
val downloads: StateFlow<Map<String, FileDownloadState>> = _downloads.asStateFlow()
/** Текущие Job'ы — чтобы не запускать повторно */
private val jobs = mutableMapOf<String, Job>()
// ─── helpers ───
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
if (!isGroupStoredKey(storedKey)) return null
val encoded = storedKey.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private fun decodeBase64Payload(data: String): ByteArray? {
val raw = data.trim()
if (raw.isBlank()) return null
val payload =
if (raw.startsWith("data:") && raw.contains(",")) raw.substringAfter(",")
else raw
return try {
android.util.Base64.decode(payload, android.util.Base64.DEFAULT)
} catch (_: Exception) {
null
}
}
// ─── public API ───
/**
* Проверяет, идёт ли уже скачивание этого attachment
*/
fun isDownloading(attachmentId: String): Boolean {
val state = _downloads.value[attachmentId] ?: return false
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
}
/**
* Возвращает Flow<FileDownloadState?> для конкретного attachment
*/
fun progressOf(attachmentId: String): Flow<FileDownloadState?> =
_downloads.map { it[attachmentId] }.distinctUntilChanged()
/**
* Запускает скачивание файла. Если уже скачивается — игнорирует.
* Скачивание продолжается даже если пользователь вышел из чата.
*/
fun download(
context: Context,
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
) {
// Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f)
jobs[attachmentId] = scope.launch {
try {
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
// Запускаем 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)
}
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(chachaKey)) {
downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
} else {
downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
}
}
progressJob.cancel()
if (success) {
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
} else {
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
} catch (_: OutOfMemoryError) {
System.gc()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
} finally {
jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения
scope.launch {
delay(5000)
_downloads.update { it - attachmentId }
}
}
}
}
/**
* Отменяет скачивание
*/
fun cancel(attachmentId: String) {
jobs[attachmentId]?.cancel()
jobs.remove(attachmentId)
_downloads.update { it - attachmentId }
}
// ─── internal download logic (moved from FileAttachment) ───
private suspend fun downloadGroupFile(
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f)
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) return false
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f)
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
return true
}
private suspend fun downloadDirectFile(
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
): Boolean {
// Streaming: скачиваем во temp file
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
return true
}
private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) {
_downloads.update { map ->
map + (id to FileDownloadState(id, fileName, status, progress))
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -91,6 +93,7 @@ import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.ThemeWallpapers
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
currentUserName: String = "", currentUserName: String = "",
totalUnreadFromOthers: Int = 0, totalUnreadFromOthers: Int = 0,
isDarkTheme: Boolean, isDarkTheme: Boolean,
chatWallpaperId: String = "",
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {} onImageViewerChanged: (Boolean) -> Unit = {}
) { ) {
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
// UI Theme // UI Theme
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val headerIconColor = Color.White val headerIconColor = Color.White
@@ -633,6 +638,14 @@ fun ChatDetailScreen(
isScreenActive = false isScreenActive = false
viewModel.setDialogActive(false) viewModel.setDialogActive(false)
} }
Lifecycle.Event.ON_STOP -> {
// Hard-stop camera/picker overlays when app goes background.
// On next app open everything must start closed/off.
showInAppCamera = false
showMediaPicker = false
pendingCameraPhotoUri = null
pendingGalleryImages = emptyList()
}
else -> {} else -> {}
} }
} }
@@ -1806,13 +1819,28 @@ fun ChatDetailScreen(
) { paddingValues -> ) { paddingValues ->
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой) // 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
// when content paddings (bottom bar/IME) change.
if (chatWallpaperResId != null) {
Image(
painter = painterResource(id = chatWallpaperResId),
contentDescription = "Chat wallpaper",
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.matchParentSize()
.background(backgroundColor)
)
}
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// 🔥 Column структура - список сжимается когда клавиатура // 🔥 Column структура - список сжимается когда клавиатура
// открывается // открывается
Column( Column(
modifier = modifier = Modifier.fillMaxSize()
Modifier.fillMaxSize()
.padding(paddingValues)
.background(backgroundColor)
) { ) {
// Список сообщений - занимает всё доступное место // Список сообщений - занимает всё доступное место
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
@@ -2487,27 +2515,6 @@ fun ChatDetailScreen(
} // Закрытие Box wrapper для Scaffold content } // Закрытие Box wrapper для Scaffold content
} // Закрытие Box } // Закрытие Box
// 📸 Image Viewer Overlay with Telegram-style shared element animation
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(
images = imageViewerImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
imageViewerImages = emptyList()
onImageViewerChanged(false)
},
onClosingStart = {
// Сразу сбрасываем status bar при начале закрытия (до анимации)
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds
)
}
// Диалог подтверждения удаления чата // Диалог подтверждения удаления чата
if (showDeleteConfirm) { if (showDeleteConfirm) {
val isLeaveGroupDialog = user.publicKey.startsWith("#group:") val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
@@ -2765,7 +2772,30 @@ fun ChatDetailScreen(
) )
} }
// 📷 In-App Camera (без системного превью!) } // Закрытие Scaffold content lambda
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(
images = imageViewerImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
imageViewerImages = emptyList()
onImageViewerChanged(false)
},
onClosingStart = {
// Сразу сбрасываем status bar при начале закрытия (до анимации)
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds
)
}
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
if (showInAppCamera) { if (showInAppCamera) {
InAppCameraScreen( InAppCameraScreen(
onDismiss = { showInAppCamera = false }, onDismiss = { showInAppCamera = false },
@@ -2827,5 +2857,5 @@ fun ChatDetailScreen(
) )
} }
} } // Закрытие outer Box
} }

View File

@@ -396,6 +396,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val currentDialogKey = getDialogKey(account, opponent) val currentDialogKey = getDialogKey(account, opponent)
if (update.dialogKey == currentDialogKey) { if (update.dialogKey == currentDialogKey) {
if (!isDialogActive) return@collect
when (update.status) { when (update.status) {
DeliveryStatus.DELIVERED -> { DeliveryStatus.DELIVERED -> {
// Обновляем конкретное сообщение // Обновляем конкретное сообщение
@@ -1157,7 +1158,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
when (entity.delivered) { when (entity.delivered) {
0 -> MessageStatus.SENDING 0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED 1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.SENT 2 -> MessageStatus.ERROR
3 -> MessageStatus.READ 3 -> MessageStatus.READ
else -> MessageStatus.SENT else -> MessageStatus.SENT
}, },
@@ -2508,8 +2509,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog(text, timestamp) saveDialog(text, timestamp)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT) // Changed from ERROR updateMessageStatus(messageId, MessageStatus.ERROR)
} }
// Update error status in DB + dialog
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog(text, timestamp)
} finally { } finally {
isSending = false isSending = false
} }
@@ -3711,7 +3715,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog(if (text.isNotEmpty()) text else "file", timestamp) saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
} finally { } finally {
isSending = false isSending = false
} }
@@ -3941,7 +3947,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog("\$a=Avatar", timestamp) saveDialog("\$a=Avatar", timestamp)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.ERROR)
android.widget.Toast.makeText( android.widget.Toast.makeText(
getApplication(), getApplication(),
"Failed to send avatar: ${e.message}", "Failed to send avatar: ${e.message}",
@@ -3949,6 +3955,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
.show() .show()
} }
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog("\$a=Avatar", timestamp)
} finally { } finally {
isSending = false isSending = false
} }
@@ -4097,7 +4105,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
content = encryptedContent, content = encryptedContent,
timestamp = timestamp, timestamp = timestamp,
chachaKey = encryptedKey, chachaKey = encryptedKey,
read = if (isFromMe) 1 else 0, read = if (isFromMe && opponent == account) 1 else 0,
fromMe = if (isFromMe) 1 else 0, fromMe = if (isFromMe) 1 else 0,
delivered = delivered, delivered = delivered,
messageId = finalMessageId, messageId = finalMessageId,

View File

@@ -442,7 +442,11 @@ fun ChatsListScreen(
val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают // <EFBFBD> Active downloads tracking (for header indicator)
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
val hasActiveDownloads = activeDownloads.isNotEmpty()
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by ProtocolManager.typingUsers.collectAsState()
// Load dialogs when account is available // Load dialogs when account is available
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
}, },
actions = { actions = {
if (!showRequestsScreen) { if (!showRequestsScreen) {
// 📥 Animated download indicator (Telegram-style)
Box(
modifier = androidx.compose.ui.Modifier.size(48.dp),
contentAlignment = Alignment.Center
) {
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
isActive = hasActiveDownloads,
color = Color.White
)
}
IconButton( IconButton(
onClick = { onClick = {
if (protocolState == if (protocolState ==
@@ -3800,30 +3814,14 @@ fun DialogItemContent(
// - lastMessageDelivered == 0 → часики // - lastMessageDelivered == 0 → часики
// (отправляется) // (отправляется)
// - lastMessageDelivered == 2 → ошибка // - lastMessageDelivered == 2 → ошибка
when (dialog.lastMessageDelivered) { val isReadByOpponent =
2 -> { dialog.lastMessageDelivered == 3 ||
// ERROR - показываем иконку ошибки (dialog.lastMessageDelivered == 1 &&
Icon( dialog.lastMessageRead == 1)
imageVector =
TablerIcons when {
.AlertCircle, isReadByOpponent -> {
contentDescription = // READ - две синие галочки
"Sending failed",
tint =
Color(
0xFFFF3B30
), // iOS красный
modifier =
Modifier.size(16.dp)
)
Spacer(
modifier =
Modifier.width(4.dp)
)
}
3 -> {
// READ (delivered=3) - две синие
// галочки
Box( Box(
modifier = modifier =
Modifier.width(20.dp) Modifier.width(20.dp)
@@ -3867,7 +3865,11 @@ fun DialogItemContent(
Modifier.width(4.dp) Modifier.width(4.dp)
) )
} }
1 -> { dialog.lastMessageDelivered == 2 -> {
// ERROR - не показываем статус рядом с временем,
// error badge показывается внизу справа (как в Telegram)
}
dialog.lastMessageDelivered == 1 -> {
// DELIVERED - одна серая галочка // DELIVERED - одна серая галочка
Icon( Icon(
painter = painter =
@@ -4102,6 +4104,27 @@ fun DialogItemContent(
} }
} }
// Error badge (Telegram-style) — красный кружок с "!" вместо unread badge
if (dialog.lastMessageFromMe == 1 && dialog.lastMessageDelivered == 2) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier.size(22.dp)
.clip(CircleShape)
.background(Color(0xFFE53935)),
contentAlignment = Alignment.Center
) {
Text(
text = "!",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
lineHeight = 13.sp,
maxLines = 1
)
}
}
// Unread badge // Unread badge
if (dialog.unreadCount > 0) { if (dialog.unreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))

View File

@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/**
* Full-screen connection logs viewer.
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConnectionLogsScreen( fun ConnectionLogsScreen(
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) { LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) { if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1) listState.animateScrollToItem(logs.size - 1)
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
.background(bgColor) .background(bgColor)
.statusBarsPadding() .statusBarsPadding()
) { ) {
// Header
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Clear button
IconButton(onClick = { ProtocolManager.clearLogs() }) { IconButton(onClick = { ProtocolManager.clearLogs() }) {
Icon( Icon(
imageVector = TablerIcons.Trash, imageVector = TablerIcons.Trash,
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
) )
} }
// Scroll to bottom
IconButton(onClick = { IconButton(onClick = {
scope.launch { scope.launch {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1) if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
} }
} }
// Status bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
) )
} }
// Logs list
if (logs.isEmpty()) { if (logs.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View File

@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity import android.app.Activity
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -25,10 +28,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
@@ -79,6 +85,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
@@ -105,6 +112,9 @@ import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.OnlineState
import com.rosetta.messenger.network.PacketOnlineState
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
@@ -149,6 +159,40 @@ private data class GroupSharedStats(
val linksCount: Int = 0 val linksCount: Int = 0
) )
private data class GroupMembersCacheEntry(
val members: List<String>,
val memberInfoByKey: Map<String, SearchUser>,
val updatedAtMs: Long
)
private object GroupMembersMemoryCache {
private const val TTL_MS = 90_000L
private val cache = mutableMapOf<String, GroupMembersCacheEntry>()
fun getAny(key: String): GroupMembersCacheEntry? = synchronized(cache) { cache[key] }
fun getFresh(key: String): GroupMembersCacheEntry? = synchronized(cache) {
val entry = cache[key] ?: return@synchronized null
if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null
}
fun put(key: String, members: List<String>, memberInfoByKey: Map<String, SearchUser>) {
if (key.isBlank()) return
synchronized(cache) {
cache[key] =
GroupMembersCacheEntry(
members = members,
memberInfoByKey = memberInfoByKey,
updatedAtMs = System.currentTimeMillis()
)
}
}
fun remove(key: String) {
synchronized(cache) { cache.remove(key) }
}
}
private data class GroupMediaItem( private data class GroupMediaItem(
val key: String, val key: String,
val attachment: MessageAttachment, val attachment: MessageAttachment,
@@ -160,12 +204,14 @@ private data class GroupMediaItem(
) )
private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)") private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
private val KEY_IMAGE_COLORS = listOf(
Color(0xFFD0EBFF), // Identicon colors — same palette for both themes (like Telegram)
Color(0xFFA5D8FF), // White stays white so the pattern is visible on dark backgrounds
Color(0xFF74C0FC), private val IDENTICON_COLORS = intArrayOf(
Color(0xFF4DABF7), 0xFFFFFFFF.toInt(),
Color(0xFF339AF0) 0xFFD0E8FF.toInt(),
0xFF228BE6.toInt(),
0xFF1971C2.toInt()
) )
@Composable @Composable
@@ -180,8 +226,6 @@ fun GroupInfoScreen(
onGroupLeft: () -> Unit = {}, onGroupLeft: () -> Unit = {},
onSwipeBackEnabledChanged: (Boolean) -> Unit = {} onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
) { ) {
BackHandler(onBack = onBack)
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val view = LocalView.current val view = LocalView.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
@@ -242,16 +286,22 @@ fun GroupInfoScreen(
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showLeaveConfirm by remember { mutableStateOf(false) } var showLeaveConfirm by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) }
var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var encryptionKeyLoading by remember { mutableStateOf(false) } var encryptionKeyLoading by remember { mutableStateOf(false) }
var membersLoading by remember { mutableStateOf(false) } var membersLoading by remember { mutableStateOf(false) }
var isMuted by remember { mutableStateOf(false) } var isMuted by remember { mutableStateOf(false) }
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) } var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() } val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf<String, Boolean>() }
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
}
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>( val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
initialValue = null, initialValue = null,
@@ -341,16 +391,32 @@ fun GroupInfoScreen(
} }
} }
fun refreshMembers() { fun refreshMembers(force: Boolean = false, showLoader: Boolean = true) {
if (normalizedGroupId.isBlank()) return if (normalizedGroupId.isBlank()) return
if (isRefreshingMembers && !force) return
scope.launch { scope.launch {
membersLoading = true if (!force) {
GroupMembersMemoryCache.getFresh(membersCacheKey)?.let { cached ->
members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
return@launch
}
}
val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null
val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
if (shouldShowLoader) membersLoading = true
isRefreshingMembers = true
try {
val fetchedMembers = withContext(Dispatchers.IO) { val fetchedMembers = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty() groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
} }
members = fetchedMembers.distinct() val distinctMembers = fetchedMembers.distinct()
membersLoading = false if (distinctMembers.isNotEmpty() || members.isEmpty()) {
members = distinctMembers
}
if (members.isEmpty()) return@launch if (members.isEmpty()) return@launch
@@ -371,31 +437,104 @@ fun GroupInfoScreen(
if (resolvedUsers.isNotEmpty()) { if (resolvedUsers.isNotEmpty()) {
memberInfoByKey.putAll(resolvedUsers) memberInfoByKey.putAll(resolvedUsers)
} }
GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
} finally {
if (shouldShowLoader) membersLoading = false
isRefreshingMembers = false
}
} }
} }
LaunchedEffect(normalizedGroupId) { LaunchedEffect(membersCacheKey) {
refreshMembers() val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
cachedEntry?.let { cached ->
members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
} }
val onlineCount by remember(members, memberInfoByKey) { if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
refreshMembers(force = true, showLoader = cachedEntry == null)
}
}
// 🟢 Subscribe to online status for group members via PacketOnlineSubscribe / PacketOnlineState
// Desktop parity: users start as OFFLINE, only become ONLINE when server sends 0x05
val onlinePacketHandler = remember<(com.rosetta.messenger.network.Packet) -> Unit>(dialogPublicKey) {
{ packet ->
val onlinePacket = packet as PacketOnlineState
onlinePacket.publicKeysState.forEach { item ->
memberOnlineStatus[item.publicKey] = (item.state == OnlineState.ONLINE)
}
}
}
DisposableEffect(dialogPublicKey) {
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
onDispose {
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
}
}
// Subscribe to online status whenever members list changes
LaunchedEffect(members, currentUserPrivateKey) {
if (members.isEmpty() || currentUserPrivateKey.isBlank()) return@LaunchedEffect
withContext(Dispatchers.IO) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
val keysToSubscribe = members.filter { key ->
!key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
}
if (keysToSubscribe.isNotEmpty()) {
val packet = PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
keysToSubscribe.forEach { addPublicKey(it) }
}
ProtocolManager.send(packet)
}
} catch (_: Exception) {}
}
}
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
val onlineCount by remember(members, memberOnlineStatus, normalizedCurrentUserKey) {
derivedStateOf { derivedStateOf {
members.count { key -> (memberInfoByKey[key]?.online ?: 0) > 0 } if (members.isEmpty()) {
0
} else {
val selfOnline = if (normalizedCurrentUserKey.isNotBlank()) 1 else 0
val othersOnline =
members.count { key ->
val isCurrentUser =
normalizedCurrentUserKey.isNotBlank() &&
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
!isCurrentUser && (memberOnlineStatus[key] == true)
}
selfOnline + othersOnline
}
} }
} }
val memberItems by remember(members, memberInfoByKey, searchQuery) { val memberItems by remember(members, memberInfoByKey, memberOnlineStatus, searchQuery) {
derivedStateOf { derivedStateOf {
val query = searchQuery.trim().lowercase() val query = searchQuery.trim().lowercase()
members.mapIndexed { index, key -> members.mapIndexed { index, key ->
val info = memberInfoByKey[key] val info = memberInfoByKey[key]
val isOnline = memberOnlineStatus[key] == true ||
key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
val fallbackName = shortPublicKey(key) val fallbackName = shortPublicKey(key)
val displayTitle = val displayTitle =
info?.title?.takeIf { it.isNotBlank() } info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() }
?: fallbackName ?: fallbackName
val subtitle = when { val subtitle = when {
(info?.online ?: 0) > 0 -> "online" isOnline -> "online"
info?.username?.isNotBlank() == true -> "@${info.username}" info?.username?.isNotBlank() == true -> "@${info.username}"
else -> key.take(18) else -> key.take(18)
} }
@@ -404,14 +543,14 @@ fun GroupInfoScreen(
title = displayTitle, title = displayTitle,
subtitle = subtitle, subtitle = subtitle,
verified = info?.verified ?: 0, verified = info?.verified ?: 0,
online = (info?.online ?: 0) > 0, online = isOnline,
isAdmin = index == 0, isAdmin = index == 0,
searchUser = SearchUser( searchUser = SearchUser(
publicKey = key, publicKey = key,
title = info?.title ?: displayTitle, title = info?.title ?: displayTitle,
username = info?.username.orEmpty(), username = info?.username.orEmpty(),
verified = info?.verified ?: 0, verified = info?.verified ?: 0,
online = info?.online ?: 0 online = if (isOnline) 1 else 0
) )
) )
}.filter { member -> }.filter { member ->
@@ -425,7 +564,6 @@ fun GroupInfoScreen(
} }
} }
} }
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) { val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
derivedStateOf { derivedStateOf {
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
@@ -435,6 +573,14 @@ fun GroupInfoScreen(
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) } var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) } var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
BackHandler {
if (showEncryptionPage) {
showEncryptionPage = false
} else {
onBack()
}
}
LaunchedEffect(selectedTab) { LaunchedEffect(selectedTab) {
if (selectedTab != GroupInfoTab.MEMBERS) { if (selectedTab != GroupInfoTab.MEMBERS) {
swipedMemberKey = null swipedMemberKey = null
@@ -541,6 +687,7 @@ fun GroupInfoScreen(
} }
isLeaving = false isLeaving = false
if (left) { if (left) {
GroupMembersMemoryCache.remove(membersCacheKey)
onGroupLeft() onGroupLeft()
} else { } else {
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
@@ -580,7 +727,12 @@ fun GroupInfoScreen(
if (removed) { if (removed) {
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) } members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
memberInfoByKey.remove(member.publicKey) memberInfoByKey.remove(member.publicKey)
refreshMembers() GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
refreshMembers(force = true, showLoader = false)
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
} else { } else {
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
@@ -605,7 +757,7 @@ fun GroupInfoScreen(
return@launch return@launch
} }
encryptionKey = key encryptionKey = key
showEncryptionDialog = true showEncryptionPage = true
} }
} }
@@ -1115,61 +1267,25 @@ fun GroupInfoScreen(
) )
} }
if (showEncryptionDialog) { AnimatedVisibility(
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) } visible = showEncryptionPage,
AlertDialog( enter = fadeIn(animationSpec = tween(durationMillis = 260)),
onDismissRequest = { showEncryptionDialog = false }, exit = fadeOut(animationSpec = tween(durationMillis = 200)),
title = { Text("Encryption key") }, modifier = Modifier.fillMaxSize()
text = {
Column {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) { ) {
DesktopStyleKeyImage( val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
keyRender = encryptionKey, GroupEncryptionKeyPage(
size = 180.dp, encryptionKey = encryptionKey,
radius = 14.dp displayLines = displayLines,
) peerTitle = groupTitle,
} isDarkTheme = isDarkTheme,
Spacer(modifier = Modifier.height(12.dp)) topSurfaceColor = topSurfaceColor,
SelectionContainer { backgroundColor = backgroundColor,
Column { onBack = { showEncryptionPage = false },
if (displayLines.isNotEmpty()) { onCopy = {
displayLines.forEach { line ->
Text(
text = line,
color = secondaryText,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
Spacer(modifier = Modifier.height(10.dp))
}
Text(
text = "This key encrypts and decrypts group messages.",
color = secondaryText,
fontSize = 12.sp
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
clipboardManager.setText(AnnotatedString(encryptionKey)) clipboardManager.setText(AnnotatedString(encryptionKey))
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
} }
) {
Text("Copy", color = accentColor)
}
},
dismissButton = {
TextButton(onClick = { showEncryptionDialog = false }) {
Text("Close", color = secondaryText)
}
}
) )
} }
@@ -1241,38 +1357,179 @@ fun GroupInfoScreen(
} }
@Composable @Composable
private fun DesktopStyleKeyImage( private fun TelegramStyleIdenticon(
keyRender: String, keyRender: String,
size: androidx.compose.ui.unit.Dp, size: androidx.compose.ui.unit.Dp,
radius: androidx.compose.ui.unit.Dp = 0.dp isDarkTheme: Boolean
) { ) {
val composition = remember(keyRender) { val palette = IDENTICON_COLORS
buildList(64) {
val source = if (keyRender.isBlank()) "rosetta" else keyRender // Convert key string to byte array, then use 2-bit grouping like Telegram's IdenticonDrawable
for (i in 0 until 64) { val keyBytes = remember(keyRender) {
val code = source[i % source.length].code val source = keyRender.ifBlank { "rosetta" }
val colorIndex = code % KEY_IMAGE_COLORS.size source.toByteArray(Charsets.UTF_8)
add(KEY_IMAGE_COLORS[colorIndex])
}
}
} }
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(RoundedCornerShape(radius))
.background(KEY_IMAGE_COLORS.first())
) { ) {
val cells = 8 val cells = 12
val cellSize = this.size.minDimension / cells.toFloat() val cellSize = this.size.minDimension / cells.toFloat()
for (i in 0 until 64) { var bitPointer = 0
val row = i / cells for (iy in 0 until cells) {
val col = i % cells for (ix in 0 until cells) {
val byteIndex = (bitPointer / 8) % keyBytes.size
val bitOffset = bitPointer % 8
val value = (keyBytes[byteIndex].toInt() shr bitOffset) and 0x3
val colorIndex = kotlin.math.abs(value) % 4
drawRect( drawRect(
color = composition[i], color = Color(palette[colorIndex]),
topLeft = Offset(col * cellSize, row * cellSize), topLeft = Offset(ix * cellSize, iy * cellSize),
size = Size(cellSize, cellSize) size = Size(cellSize, cellSize)
) )
bitPointer += 2
}
}
}
}
@Composable
private fun GroupEncryptionKeyPage(
encryptionKey: String,
displayLines: List<String>,
peerTitle: String,
isDarkTheme: Boolean,
topSurfaceColor: Color,
backgroundColor: Color,
onBack: () -> Unit,
onCopy: () -> Unit
) {
val uriHandler = LocalUriHandler.current
val safePeerTitle = peerTitle.ifBlank { "this group" }
// Rosetta theme colors
val identiconBg = if (isDarkTheme) Color(0xFF111111) else Color(0xFFF2F2F7)
val bottomBg = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
val codeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF6D7883)
val descriptionColor = Color(0xFF8E8E93)
val linkColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
.background(topSurfaceColor)
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Text(
text = "Encryption Key",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onCopy) {
Text(
text = "Copy",
color = Color.White.copy(alpha = 0.9f),
fontWeight = FontWeight.Medium
)
}
}
// Two-half layout like Telegram
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f)
) {
// Top half - Identicon image on gray background
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(identiconBg)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
TelegramStyleIdenticon(
keyRender = encryptionKey,
size = 280.dp,
isDarkTheme = isDarkTheme
)
}
// Bottom half - Code + Description on white background
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(bottomBg)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Hex code display
SelectionContainer {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
displayLines.forEach { line ->
Text(
text = line,
color = codeColor,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
letterSpacing = 0.5.sp,
textAlign = TextAlign.Center
)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// Description text
Text(
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.\n\nIf they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
color = descriptionColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Learn more at rosetta.im",
color = linkColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.clickable { uriHandler.openUri("https://rosetta.im/") }
)
Spacer(modifier = Modifier.height(16.dp))
}
}
} }
} }
} }
@@ -1772,14 +2029,24 @@ private fun encodeGroupKeyForDisplay(encryptKey: String): List<String> {
val normalized = encryptKey.trim() val normalized = encryptKey.trim()
if (normalized.isBlank()) return emptyList() if (normalized.isBlank()) return emptyList()
val lines = mutableListOf<String>() // Telegram-style: each char → XOR 27 → 2-char hex, grouped as:
normalized.chunked(16).forEach { chunk -> // "ab cd ef 12 34 56 78 9a" (4 pairs + double space + 4 pairs per line)
val bytes = mutableListOf<String>() val hexPairs = normalized.map { symbol ->
chunk.forEach { symbol -> (symbol.code xor 27).toString(16).padStart(2, '0')
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
bytes.add(encoded)
} }
lines.add(bytes.joinToString(" "))
val lines = mutableListOf<String>()
for (lineStart in hexPairs.indices step 8) {
val lineEnd = minOf(lineStart + 8, hexPairs.size)
val linePairs = hexPairs.subList(lineStart, lineEnd)
val sb = StringBuilder()
for ((i, pair) in linePairs.withIndex()) {
if (i > 0) {
sb.append(if (i % 4 == 0) " " else " ")
}
sb.append(pair)
}
lines.add(sb.toString())
} }
return lines return lines
} }

View File

@@ -1,61 +1,186 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.content.Context
import android.net.Uri
import android.app.Activity
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
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.TextStyle
import androidx.compose.ui.text.style.TextAlign
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.core.view.WindowCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) private enum class GroupSetupStep {
DETAILS,
DESCRIPTION
}
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
fun GroupSetupScreen( fun GroupSetupScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
accountPublicKey: String, accountPublicKey: String,
accountPrivateKey: String, accountPrivateKey: String,
accountName: String,
accountUsername: String,
avatarRepository: AvatarRepository? = null,
onBack: () -> Unit, onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit onGroupOpened: (SearchUser) -> Unit
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val nameFocusRequester = remember { FocusRequester() }
var selectedTab by remember { mutableIntStateOf(0) } var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
var title by remember { mutableStateOf("") } var title by rememberSaveable { mutableStateOf("") }
var description by remember { mutableStateOf("") } var description by rememberSaveable { mutableStateOf("") }
var inviteString by remember { mutableStateOf("") } var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) } var isLoading by rememberSaveable { mutableStateOf(false) }
var errorText by remember { mutableStateOf<String?>(null) } var errorText by rememberSaveable { mutableStateOf<String?>(null) }
var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) }
var showPhotoPicker by rememberSaveable { mutableStateOf(false) }
val coordinator = rememberKeyboardTransitionCoordinator()
var lastToggleTime by remember { mutableLongStateOf(0L) }
val toggleCooldownMs = 500L
val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
val cropError = ImageCropHelper.getCropError(result)
if (croppedUri != null) {
selectedAvatarUri = croppedUri.toString()
} else if (cropError != null) {
android.widget.Toast
.makeText(context, "Failed to crop photo", android.widget.Toast.LENGTH_SHORT)
.show()
}
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val topSurfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val sectionColor = if (isDarkTheme) Color(0xFF222224) else Color.White
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
val window = (view.context as? Activity)?.window
if (window == null) {
onDispose { }
} else {
val controller = WindowCompat.getInsetsController(window, view)
val previousColor = window.statusBarColor
val previousLightIcons = controller.isAppearanceLightStatusBars
window.statusBarColor = topSurfaceColor.toArgb()
controller.isAppearanceLightStatusBars = false
onDispose {
window.statusBarColor = previousColor
controller.isAppearanceLightStatusBars = previousLightIcons
}
}
}
val normalizedUsername = remember(accountUsername) {
accountUsername.trim().trimStart('@')
}
val selfTitle =
remember(accountName, normalizedUsername, accountPublicKey) {
accountName.trim()
.ifBlank { normalizedUsername }
.ifBlank { shortPublicKey(accountPublicKey) }
}
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you"
fun openGroup(dialogPublicKey: String, groupTitle: String) { fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened( onGroupOpened(
@@ -77,13 +202,6 @@ fun GroupSetupScreen(
description = description.trim() description = description.trim()
) )
suspend fun joinGroup() =
GroupRepository.getInstance(context).joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = inviteString.trim()
)
fun mapError(status: GroupStatus, fallback: String): String { fun mapError(status: GroupStatus, fallback: String): String {
return when (status) { return when (status) {
GroupStatus.BANNED -> "You are banned in this group" GroupStatus.BANNED -> "You are banned in this group"
@@ -92,157 +210,615 @@ fun GroupSetupScreen(
} }
} }
Scaffold( fun handleBack() {
topBar = { if (isLoading) return
TopAppBar( errorText = null
title = { Text("Groups", fontWeight = FontWeight.SemiBold) }, if (step == GroupSetupStep.DESCRIPTION) {
navigationIcon = { step = GroupSetupStep.DETAILS
TextButton(onClick = onBack) { } else {
Text("Back") onBack()
} }
} }
BackHandler(onBack = ::handleBack)
val canGoNext = title.trim().isNotEmpty()
val canCreate = canGoNext && !isLoading
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val imeBottomDp = with(density) { imeBottomPx.toDp() }
LaunchedEffect(step) {
if (step != GroupSetupStep.DETAILS) {
if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
}
}
LaunchedEffect(Unit) {
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
if (savedPx > 0) {
coordinator.initializeEmojiHeight(with(density) { savedPx.toDp() })
}
}
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
var isKeyboardVisible by remember { mutableStateOf(false) }
LaunchedEffect(imeBottomPx) {
val currentImeHeight = with(density) { imeBottomPx.toDp() }
coordinator.updateKeyboardHeight(currentImeHeight)
isKeyboardVisible = currentImeHeight > 50.dp
if (currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
}
}
// Save keyboard height for emoji picker sync
LaunchedEffect(isKeyboardVisible, showEmojiKeyboard) {
if (isKeyboardVisible && !showEmojiKeyboard) {
kotlinx.coroutines.delay(350)
if (isKeyboardVisible && !showEmojiKeyboard && lastStableKeyboardHeight > 300.dp) {
val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() }
KeyboardHeightProvider.saveKeyboardHeight(context, heightPx)
}
}
}
fun toggleEmojiPicker() {
val now = System.currentTimeMillis()
if (now - lastToggleTime < toggleCooldownMs || step != GroupSetupStep.DETAILS || isLoading) return
lastToggleTime = now
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (coordinator.isEmojiVisible) {
coordinator.requestShowKeyboard(
showKeyboard = {
nameFocusRequester.requestFocus()
keyboardController?.show()
@Suppress("DEPRECATION")
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
},
hideEmoji = { showEmojiKeyboard = false }
)
return
}
if (imeBottomPx > 0) {
coordinator.requestShowEmoji(
hideKeyboard = {
imm.hideSoftInputFromWindow(view.windowToken, 0)
keyboardController?.hide()
},
showEmoji = {
focusManager.clearFocus(force = true)
showEmojiKeyboard = true
}
)
return
}
if (coordinator.emojiHeight == 0.dp) {
val fallbackHeight = if (lastStableKeyboardHeight > 0.dp) {
lastStableKeyboardHeight
} else {
with(density) { KeyboardHeightProvider.getSavedKeyboardHeight(context).toDp() }
}
coordinator.initializeEmojiHeight(fallbackHeight)
}
coordinator.openEmojiOnly(
showEmoji = {
focusManager.clearFocus(force = true)
showEmojiKeyboard = true
}
) )
} }
LaunchedEffect(Unit) {
if (step == GroupSetupStep.DETAILS) {
delay(120)
nameFocusRequester.requestFocus()
keyboardController?.show()
}
}
Scaffold(
containerColor = backgroundColor,
topBar = {
TopAppBar(
title = {
Text(
text = if (step == GroupSetupStep.DETAILS) "New Group" else "Group Description",
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 20.sp
)
},
navigationIcon = {
IconButton(onClick = ::handleBack) {
Icon(
painter = TelegramIcons.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = topSurfaceColor,
titleContentColor = Color.White,
navigationIconContentColor = Color.White
)
)
},
bottomBar = {
if (step == GroupSetupStep.DETAILS) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiKeyboard
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emojiCode ->
val emoji = decodeEmojiCodeToUnicode(emojiCode)
title = (title + emoji).take(80)
},
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { paddingValues -> ) { paddingValues ->
// Outer Box fills the full Scaffold area (no paddingValues applied)
// so the FAB can be positioned consistently above *any* keyboard.
Box(modifier = Modifier.fillMaxSize()) {
// Content area respects Scaffold padding (topBar + bottomBar/emoji)
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp)
verticalArrangement = Arrangement.Top .navigationBarsPadding()
) { ) {
TabRow(selectedTabIndex = selectedTab) { if (step == GroupSetupStep.DETAILS) {
Tab(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
errorText = null
},
text = { Text("Create") }
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
errorText = null
},
text = { Text("Join") }
)
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (selectedTab == 0) { Row(
OutlinedTextField( modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(72.dp)
) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(CircleShape)
.background(sectionColor)
.clickable(enabled = !isLoading) {
showPhotoPicker = true
},
contentAlignment = Alignment.Center
) {
if (!selectedAvatarUri.isNullOrBlank()) {
AsyncImage(
model =
ImageRequest.Builder(context)
.data(Uri.parse(selectedAvatarUri))
.crossfade(true)
.build(),
contentDescription = "Group avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Camera,
contentDescription = "Set group avatar",
tint = accentColor,
modifier = Modifier.size(24.dp)
)
}
}
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = title, value = title,
onValueChange = { title = it }, onValueChange = { newValue -> title = newValue.take(80) },
label = { Text("Group title") },
singleLine = true, singleLine = true,
textStyle = TextStyle(
color = primaryTextColor,
fontSize = 18.sp,
fontWeight = FontWeight.Medium
),
cursorBrush = SolidColor(accentColor),
enabled = !isLoading,
modifier =
Modifier
.fillMaxWidth()
.focusRequester(nameFocusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused &&
showEmojiKeyboard &&
!coordinator.isTransitioning
) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
}
.padding(vertical = 2.dp),
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp) contentAlignment = Alignment.CenterStart
) {
if (title.isBlank()) {
Text(
text = "Group name",
color = secondaryTextColor.copy(alpha = 0.88f),
fontSize = 18.sp,
fontWeight = FontWeight.Normal
) )
Spacer(modifier = Modifier.height(12.dp)) }
OutlinedTextField( innerTextField()
}
}
)
}
IconButton(
onClick = { toggleEmojiPicker() },
enabled = !isLoading
) {
Icon(
painter = if (showEmojiKeyboard || coordinator.isEmojiBoxVisible) {
TelegramIcons.Keyboard
} else {
TelegramIcons.Smile
},
contentDescription = "Emoji",
tint = secondaryTextColor,
modifier = Modifier.size(22.dp)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier =
Modifier
.fillMaxWidth()
.height(2.dp)
.background(accentColor.copy(alpha = 0.9f), RoundedCornerShape(1.dp))
)
}
}
Spacer(modifier = Modifier.height(22.dp))
Text(
text = "1 member",
color = accentColor,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
modifier = Modifier.padding(bottom = 0.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(sectionColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
size = 50.dp,
isDarkTheme = isDarkTheme,
displayName = selfTitle
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = selfTitle,
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
contentDescription = "Admin",
tint = Color(0xFFF6C445),
modifier = Modifier.size(14.dp)
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = selfSubtitle,
color = secondaryTextColor,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "After creating the group, you can invite as many users as you need.",
color = secondaryTextColor,
fontSize = 13.sp,
lineHeight = 18.sp
)
} else {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(cardColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(50.dp).clip(CircleShape).background(accentColor.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
if (!selectedAvatarUri.isNullOrBlank()) {
AsyncImage(
model =
ImageRequest.Builder(context)
.data(Uri.parse(selectedAvatarUri))
.crossfade(true)
.build(),
contentDescription = "Group avatar preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Photos,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title.trim(),
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Add a description (optional)",
color = secondaryTextColor,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Description",
color = secondaryTextColor,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { newValue -> description = newValue.take(400) },
label = { Text("Description (optional)") }, modifier =
modifier = Modifier.fillMaxWidth(), Modifier
shape = RoundedCornerShape(12.dp), .fillMaxWidth()
minLines = 3, .height(150.dp)
maxLines = 4 .clip(RoundedCornerShape(14.dp))
.background(sectionColor),
minLines = 6,
maxLines = 8,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = primaryTextColor, fontSize = 16.sp),
placeholder = {
Text(
text = "Group description",
color = secondaryTextColor,
fontSize = 16.sp
) )
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot create group"
)
}
isLoading = false
}
}, },
modifier = Modifier.fillMaxWidth(), colors =
enabled = title.trim().isNotEmpty() && !isLoading TextFieldDefaults.colors(
) { focusedTextColor = primaryTextColor,
if (isLoading) { unfocusedTextColor = primaryTextColor,
CircularProgressIndicator(strokeWidth = 2.dp) focusedContainerColor = sectionColor,
} else { unfocusedContainerColor = sectionColor,
Text("Create Group") disabledContainerColor = sectionColor,
} focusedIndicatorColor = Color.Transparent,
} unfocusedIndicatorColor = Color.Transparent,
} else { focusedPlaceholderColor = secondaryTextColor,
OutlinedTextField( unfocusedPlaceholderColor = secondaryTextColor,
value = inviteString, cursorColor = accentColor
onValueChange = { inviteString = it },
label = { Text("Invite string") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6,
shape = RoundedCornerShape(12.dp)
) )
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { joinGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot join group"
) )
}
isLoading = false Spacer(modifier = Modifier.height(8.dp))
} Text(
}, text = "Description is optional. You can change it later in group info.",
modifier = Modifier.fillMaxWidth(), color = secondaryTextColor,
enabled = inviteString.trim().isNotEmpty() && !isLoading fontSize = 13.sp,
) { textAlign = TextAlign.Start
if (isLoading) { )
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Join Group")
}
}
} }
if (!errorText.isNullOrBlank()) { if (!errorText.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = errorText ?: "", text = errorText.orEmpty(),
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
) )
} }
}
Spacer(modifier = Modifier.height(16.dp)) FloatingActionButton(
Row(modifier = Modifier.fillMaxWidth()) { onClick = {
Text( if (step == GroupSetupStep.DETAILS) {
text = if (canGoNext) {
if (selectedTab == 0) { errorText = null
"Creates a new private group and joins it automatically." step = GroupSetupStep.DESCRIPTION
}
return@FloatingActionButton
}
if (!canCreate) return@FloatingActionButton
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
withContext(Dispatchers.IO) {
persistLocalGroupAvatar(
context = context,
avatarRepository = avatarRepository,
groupDialogPublicKey = result.dialogPublicKey,
avatarUriString = selectedAvatarUri
)
}
openGroup(
dialogPublicKey = result.dialogPublicKey,
groupTitle = result.title.ifBlank { title.trim() }
)
} else { } else {
"Paste a full invite string that starts with #group:." errorText = mapError(result.status, result.error ?: "Cannot create group")
}
isLoading = false
}
}, },
style = MaterialTheme.typography.bodySmall, containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
color = MaterialTheme.colorScheme.onSurfaceVariant contentColor = Color.White,
shape = CircleShape,
modifier = run {
// Берём максимум из всех позиций — при переключении keyboard↔emoji
// одна уходит вниз, другая уже на месте, FAB не прыгает.
val keyboardBottom = if (imeBottomDp > 0.dp) imeBottomDp + 14.dp else 0.dp
val emojiBottom = if (coordinator.isEmojiBoxVisible && coordinator.emojiHeight > 0.dp) coordinator.emojiHeight + 14.dp else 0.dp
val fabBottom = maxOf(keyboardBottom, emojiBottom, 18.dp)
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = fabBottom)
.size(58.dp)
}
) {
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(22.dp)
)
} else {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Continue"
) )
} }
} }
} }
}
ProfilePhotoPicker(
isVisible = showPhotoPicker,
onDismiss = { showPhotoPicker = false },
onPhotoSelected = { uri ->
showPhotoPicker = false
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
cropLauncher.launch(cropIntent)
},
isDarkTheme = isDarkTheme
)
}
private suspend fun persistLocalGroupAvatar(
context: android.content.Context,
avatarRepository: AvatarRepository?,
groupDialogPublicKey: String,
avatarUriString: String?
) {
val repository = avatarRepository ?: return
val safeUri = avatarUriString?.takeIf { it.isNotBlank() } ?: return
val uri = runCatching { Uri.parse(safeUri) }.getOrNull() ?: return
val rawBytes =
runCatching {
context.contentResolver.openInputStream(uri)?.use { stream -> stream.readBytes() }
}.getOrNull() ?: return
if (rawBytes.isEmpty()) return
val preparedBase64 = AvatarFileManager.imagePrepareForNetworkTransfer(context, rawBytes)
if (preparedBase64.isBlank()) return
repository.saveAvatar(
fromPublicKey = groupDialogPublicKey,
base64Image = "data:image/png;base64,$preparedBase64"
)
}
private fun decodeEmojiCodeToUnicode(value: String): String {
val match = Regex("^:emoji_([a-fA-F0-9_-]+):$").matchEntire(value) ?: return value
val codePoints =
match.groupValues[1]
.split("-")
.mapNotNull { code -> code.toIntOrNull(16) }
if (codePoints.isEmpty()) return value
return String(codePoints.toIntArray(), 0, codePoints.size)
}
private fun shortPublicKey(publicKey: String): String {
val normalized = publicKey.trim()
return if (normalized.length <= 12) normalized else "${normalized.take(6)}...${normalized.takeLast(4)}"
} }

View File

@@ -122,6 +122,8 @@ internal fun CameraGridItem(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
val iconScale = remember { Animatable(0f) } val iconScale = remember { Animatable(0f) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -145,6 +147,21 @@ internal fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
DisposableEffect(lifecycleOwner, hasCameraPermission) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
if (provider != null && preview != null) {
try {
provider.unbind(preview)
} catch (_: Exception) {
}
}
previewUseCase = null
cameraProvider = null
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -164,17 +181,24 @@ internal fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
try { try {
val cameraProvider = cameraProviderFuture.get() val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also { val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider) it.setSurfaceProvider(previewView.surfaceProvider)
} }
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll() previewUseCase?.let { existing ->
cameraProvider.bindToLifecycle( try {
provider.unbind(existing)
} catch (_: Exception) {
}
}
provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
cameraSelector, cameraSelector,
preview preview
) )
previewUseCase = preview
} catch (_: Exception) { } catch (_: Exception) {
// Camera init failed // Camera init failed
} }

View File

@@ -440,6 +440,17 @@ fun ChatAttachAlert(
} else { } else {
requestPermissions() requestPermissions()
} }
} else if (shouldShow || isClosing) {
// Parent hidden externally (e.g. app background): force-close immediately.
// This guarantees camera preview does not stay active across app reopen.
pendingCaptionFocus = false
captionInputActive = false
showEmojiPicker = false
coordinator.isEmojiVisible = false
coordinator.isEmojiBoxVisible = false
isClosing = false
shouldShow = false
showAlbumMenu = false
} }
} }

View File

@@ -1433,7 +1433,6 @@ fun FileAttachment(
messageStatus: MessageStatus = MessageStatus.READ messageStatus: MessageStatus = MessageStatus.READ
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
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) }
@@ -1465,7 +1464,30 @@ fun FileAttachment(
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } } val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
val savedFile = remember(fileName) { File(downloadsDir, fileName) } val savedFile = remember(fileName) { File(downloadsDir, fileName) }
// 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
val managerState by com.rosetta.messenger.network.FileDownloadManager
.progressOf(attachment.id)
.collectAsState(initial = null)
// Синхронизируем локальный UI с глобальным менеджером
LaunchedEffect(managerState) {
val state = managerState ?: return@LaunchedEffect
downloadProgress = state.progress
downloadStatus = when (state.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> 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
}
}
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
// Если менеджер уже качает этот файл — подхватим состояние оттуда
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
downloadStatus = DownloadStatus.DOWNLOADING
return@LaunchedEffect
}
downloadStatus = if (isDownloadTag(preview)) { downloadStatus = if (isDownloadTag(preview)) {
// Проверяем, был ли файл уже скачан ранее // Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED if (savedFile.exists()) DownloadStatus.DOWNLOADED
@@ -1507,76 +1529,20 @@ fun FileAttachment(
} }
} }
// 📥 Запуск скачивания через глобальный FileDownloadManager
val download: () -> Unit = { val download: () -> Unit = {
if (downloadTag.isNotEmpty()) { if (downloadTag.isNotEmpty()) {
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
downloadProgress = 0f
// Streaming: скачиваем во temp file, не в память com.rosetta.messenger.network.FileDownloadManager.download(
val success = context = context,
if (isGroupStoredKey(chachaKey)) { attachmentId = attachment.id,
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) downloadTag = downloadTag,
downloadProgress = 0.5f chachaKey = chachaKey,
downloadStatus = DownloadStatus.DECRYPTING privateKey = privateKey,
fileName = fileName,
val groupPassword = decodeGroupPassword(chachaKey, privateKey) savedFile = savedFile
if (groupPassword.isNullOrBlank()) {
false
} else {
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
val bytes = decrypted?.let { decodeBase64Payload(it) }
if (bytes != null) {
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
true
} else {
false
}
}
} else {
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
) )
} finally {
tempFile.delete()
}
}
}
downloadProgress = 0.95f
if (success) {
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
} else {
downloadStatus = DownloadStatus.ERROR
}
} catch (e: Exception) {
e.printStackTrace()
downloadStatus = DownloadStatus.ERROR
} catch (_: OutOfMemoryError) {
System.gc()
downloadStatus = DownloadStatus.ERROR
}
}
} }
} }
@@ -1623,7 +1589,9 @@ fun FileAttachment(
) { ) {
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Determinate progress like Telegram
CircularProgressIndicator( CircularProgressIndicator(
progress = downloadProgress.coerceIn(0f, 1f),
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
color = Color.White, color = Color.White,
strokeWidth = 2.dp strokeWidth = 2.dp
@@ -1693,10 +1661,14 @@ fun FileAttachment(
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.DOWNLOADING -> { DownloadStatus.DOWNLOADING -> {
AnimatedDotsText( // Telegram-style: "1.2 MB / 5.4 MB"
baseText = "Downloading", // CDN download maps to progress 0..0.8
color = statusColor, val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
fontSize = 12.sp val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
) )
} }
DownloadStatus.DECRYPTING -> { DownloadStatus.DECRYPTING -> {

View File

@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -592,11 +593,19 @@ fun MessageBubble(
.IMAGE .IMAGE
} }
val isStandaloneGroupInvite =
message.attachments.isEmpty() &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.text.isNotBlank() &&
isGroupInviteCode(message.text)
// Для сообщений только с фото - минимальный padding и тонкий border // Для сообщений только с фото - минимальный padding и тонкий border
// Для фото + caption - padding только внизу для текста // Для фото + caption - padding только внизу для текста
val bubblePadding = val bubblePadding =
when { when {
isSafeSystemMessage -> PaddingValues(0.dp) isSafeSystemMessage -> PaddingValues(0.dp)
isStandaloneGroupInvite -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
hasOnlyMedia -> PaddingValues(0.dp) hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp) hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -676,6 +685,8 @@ fun MessageBubble(
val bubbleWidthModifier = val bubbleWidthModifier =
if (isSafeSystemMessage) { if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp) Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) { } else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width( Modifier.width(
photoWidth photoWidth
@@ -703,7 +714,11 @@ fun MessageBubble(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
) )
.clip(bubbleShape) .then(
if (false) {
Modifier
} else {
Modifier.clip(bubbleShape)
.then( .then(
if (hasOnlyMedia) { if (hasOnlyMedia) {
Modifier.border( Modifier.border(
@@ -743,6 +758,8 @@ fun MessageBubble(
} }
) )
.padding(bubblePadding) .padding(bubblePadding)
}
)
) { ) {
if (isSafeSystemMessage) { if (isSafeSystemMessage) {
SafeSystemMessageCard( SafeSystemMessageCard(
@@ -1045,35 +1062,12 @@ fun MessageBubble(
accountPublicKey = currentUserPublicKey, accountPublicKey = currentUserPublicKey,
accountPrivateKey = privateKey, accountPrivateKey = privateKey,
actionsEnabled = !isSelectionMode, actionsEnabled = !isSelectionMode,
timestamp = message.timestamp,
messageStatus = displayStatus,
onRetry = onRetry,
onDelete = onDelete,
onOpenGroup = onGroupInviteOpen onOpenGroup = onGroupInviteOpen
) )
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = displayStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = message.isOutgoing,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
} else { } else {
// Telegram-style: текст + время с автоматическим // Telegram-style: текст + время с автоматическим
// переносом // переносом
@@ -1275,6 +1269,10 @@ private fun GroupInviteInlineCard(
accountPublicKey: String, accountPublicKey: String,
accountPrivateKey: String, accountPrivateKey: String,
actionsEnabled: Boolean, actionsEnabled: Boolean,
timestamp: Date,
messageStatus: MessageStatus,
onRetry: () -> Unit,
onDelete: () -> Unit,
onOpenGroup: (SearchUser) -> Unit onOpenGroup: (SearchUser) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -1284,9 +1282,10 @@ private fun GroupInviteInlineCard(
val normalizedInvite = remember(inviteText) { inviteText.trim() } val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) } val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) } val cachedInfo = remember(normalizedInvite) { parsedInvite?.let { groupRepository.getCachedInviteInfo(it.groupId) } }
var membersCount by remember(normalizedInvite) { mutableStateOf(0) } var status by remember(normalizedInvite) { mutableStateOf(cachedInfo?.status ?: GroupStatus.NOT_JOINED) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) } var membersCount by remember(normalizedInvite) { mutableStateOf(cachedInfo?.membersCount ?: 0) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(cachedInfo == null) }
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) } var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
LaunchedEffect(normalizedInvite, accountPublicKey) { LaunchedEffect(normalizedInvite, accountPublicKey) {
@@ -1297,7 +1296,9 @@ private fun GroupInviteInlineCard(
return@LaunchedEffect return@LaunchedEffect
} }
if (cachedInfo == null) {
statusLoading = true statusLoading = true
}
val localGroupExists = val localGroupExists =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -1314,12 +1315,13 @@ private fun GroupInviteInlineCard(
} }
membersCount = inviteInfo?.membersCount ?: 0 membersCount = inviteInfo?.membersCount ?: 0
status = val newStatus = when {
when {
localGroupExists -> GroupStatus.JOINED localGroupExists -> GroupStatus.JOINED
inviteInfo != null -> inviteInfo.status inviteInfo != null -> inviteInfo.status
else -> GroupStatus.NOT_JOINED else -> GroupStatus.NOT_JOINED
} }
status = newStatus
groupRepository.cacheInviteInfo(parsedInvite.groupId, newStatus, membersCount)
statusLoading = false statusLoading = false
} }
@@ -1350,30 +1352,16 @@ private fun GroupInviteInlineCard(
} }
} }
val cardBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.16f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.06f)
} else {
Color.Black.copy(alpha = 0.03f)
}
val cardBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.22f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.12f)
} else {
Color.Black.copy(alpha = 0.08f)
}
val titleColor =
if (isOutgoing) Color.White
else if (isDarkTheme) Color.White
else Color(0xFF1A1A1A)
val subtitleColor = val subtitleColor =
if (isOutgoing) Color.White.copy(alpha = 0.82f) if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFA9AFBA) else if (isDarkTheme) Color(0xFFA9AFBA)
else Color(0xFF70757F) else Color(0xFF70757F)
val timeColor =
if (isOutgoing) Color.White.copy(alpha = 0.74f)
else if (isDarkTheme) Color(0xFF8E8E93)
else Color(0xFF666666)
val statusColor = if (isOutgoing) Color.White else timeColor
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val accentColor = val accentColor =
when (status) { when (status) {
@@ -1441,6 +1429,7 @@ private fun GroupInviteInlineCard(
if (joinResult.success) { if (joinResult.success) {
status = GroupStatus.JOINED status = GroupStatus.JOINED
groupRepository.cacheInviteInfo(parsedInvite.groupId, GroupStatus.JOINED, membersCount)
openParsedGroup() openParsedGroup()
} else { } else {
status = joinResult.status status = joinResult.status
@@ -1455,28 +1444,27 @@ private fun GroupInviteInlineCard(
} }
} }
Surface( Column {
modifier = Modifier.fillMaxWidth(), // Icon + Title row
color = cardBackground,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Group icon circle
Box( Box(
modifier = modifier = Modifier
Modifier.size(34.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)), .background(
if (isOutgoing) Color.White.copy(alpha = 0.15f)
else accentColor.copy(alpha = 0.12f)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Default.Link, imageVector = Icons.Default.Groups,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = if (isOutgoing) Color.White else accentColor,
modifier = Modifier.size(18.dp) modifier = Modifier.size(20.dp)
) )
} }
@@ -1485,63 +1473,93 @@ private fun GroupInviteInlineCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = title, text = title,
color = titleColor, color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color(0xFF1A1A1A),
fontSize = 14.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(1.dp))
Text( Text(
text = subtitle, text = subtitle,
color = subtitleColor, color = subtitleColor,
fontSize = 11.sp, fontSize = 12.sp,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Action button (full width)
Surface( Surface(
modifier = modifier = Modifier
Modifier.clip(RoundedCornerShape(8.dp)).clickable( .fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(
enabled = actionEnabled, enabled = actionEnabled,
onClick = ::handleAction onClick = ::handleAction
), ),
color = color = if (isOutgoing) Color.White.copy(alpha = 0.18f)
if (isOutgoing) { else accentColor.copy(alpha = 0.10f),
Color.White.copy(alpha = 0.2f)
} else {
accentColor.copy(alpha = 0.14f)
},
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), modifier = Modifier.padding(vertical = 7.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Spacer(modifier = Modifier.weight(1f))
if (actionLoading || statusLoading) { if (actionLoading || statusLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(12.dp), modifier = Modifier.size(13.dp),
strokeWidth = 1.8.dp, strokeWidth = 1.5.dp,
color = accentColor color = if (isOutgoing) Color.White else accentColor
) )
} else { } else {
Icon( Icon(
imageVector = actionIcon, imageVector = actionIcon,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = if (isOutgoing) Color.White else accentColor,
modifier = Modifier.size(12.dp) modifier = Modifier.size(14.dp)
) )
} }
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = actionLabel, text = actionLabel,
color = accentColor, color = if (isOutgoing) Color.White else accentColor,
fontSize = 11.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
Spacer(modifier = Modifier.weight(1f))
} }
} }
Spacer(modifier = Modifier.height(4.dp))
// Time + status row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(timestamp),
color = timeColor,
fontSize = 11.sp
)
if (isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = messageStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = true,
timestamp = timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
} }
} }
} }
@@ -1632,6 +1650,19 @@ fun AnimatedMessageStatus(
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {} onDelete: () -> Unit = {}
) { ) {
// Force recomposition when SENDING message exceeds 80s timeout
var timeoutTick by remember { mutableLongStateOf(0L) }
if (status == MessageStatus.SENDING && timestamp > 0) {
val remainingMs = (timestamp + 80_000L) - System.currentTimeMillis()
if (remainingMs > 0) {
LaunchedEffect(timestamp) {
kotlinx.coroutines.delay(remainingMs + 100)
timeoutTick++
}
}
}
@Suppress("UNUSED_EXPRESSION") timeoutTick // read to subscribe
val isTimedOut = val isTimedOut =
status == MessageStatus.SENDING && status == MessageStatus.SENDING &&
timestamp > 0 && timestamp > 0 &&
@@ -1688,18 +1719,30 @@ fun AnimatedMessageStatus(
label = "statusIcon" label = "statusIcon"
) { currentStatus -> ) { currentStatus ->
if (currentStatus == MessageStatus.ERROR) { if (currentStatus == MessageStatus.ERROR) {
Icon( // Telegram-style: red filled circle with white "!" inside
imageVector = TablerIcons.AlertCircle, Box(
contentDescription = null,
tint = animatedColor,
modifier = modifier =
Modifier.size(iconSize) Modifier.padding(start = 4.dp)
.size(iconSize)
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.scale(scale) .scale(scale)
.background(
color = Color(0xFFE53935),
shape = CircleShape
)
.clickable { .clickable {
showErrorMenu = true showErrorMenu = true
} },
contentAlignment = Alignment.Center
) {
Text(
text = "!",
color = Color.White,
fontSize = 10.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
lineHeight = 10.sp
) )
}
} else { } else {
if (currentStatus == MessageStatus.READ) { if (currentStatus == MessageStatus.READ) {
Box( Box(
@@ -1746,39 +1789,76 @@ fun AnimatedMessageStatus(
} }
} }
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222)
val menuIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = menuBgColor,
onSurface = menuTextColor
)
) {
DropdownMenu( DropdownMenu(
expanded = showErrorMenu, expanded = showErrorMenu,
onDismissRequest = { showErrorMenu = false } onDismissRequest = { showErrorMenu = false },
modifier = Modifier
.defaultMinSize(minWidth = 196.dp)
.background(menuBgColor)
) { ) {
DropdownMenuItem( Box(
text = { Text("Retry") }, modifier = Modifier
onClick = { .fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable {
showErrorMenu = false showErrorMenu = false
onRetry() onRetry()
}, }
leadingIcon = { .padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
painter = TelegramIcons.Retry, painter = TelegramIcons.Retry,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) tint = menuIconColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = "Retry",
color = menuTextColor,
fontSize = 16.sp
) )
} }
) }
DropdownMenuItem( Box(
text = { Text("Delete", color = Color(0xFFE53935)) }, modifier = Modifier
onClick = { .fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable {
showErrorMenu = false showErrorMenu = false
onDelete() onDelete()
}, }
leadingIcon = { .padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
painter = TelegramIcons.Delete, painter = TelegramIcons.Delete,
contentDescription = null, contentDescription = null,
tint = Color(0xFFE53935), tint = Color(0xFFFF3B30),
modifier = Modifier.size(18.dp) modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = "Delete",
color = Color(0xFFFF3B30),
fontSize = 16.sp
) )
} }
) }
}
} }
} }
} }

View File

@@ -1668,6 +1668,8 @@ private fun CameraGridItem(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
// Bounce animation for camera icon // Bounce animation for camera icon
val iconScale = remember { Animatable(0f) } val iconScale = remember { Animatable(0f) }
@@ -1699,6 +1701,21 @@ private fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
DisposableEffect(lifecycleOwner, hasCameraPermission) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
if (provider != null && preview != null) {
try {
provider.unbind(preview)
} catch (_: Exception) {
}
}
previewUseCase = null
cameraProvider = null
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -1719,7 +1736,8 @@ private fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
try { try {
val cameraProvider = cameraProviderFuture.get() val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also { val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider) it.setSurfaceProvider(previewView.surfaceProvider)
@@ -1728,12 +1746,18 @@ private fun CameraGridItem(
// Use back camera // Use back camera
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll() previewUseCase?.let { existing ->
cameraProvider.bindToLifecycle( try {
provider.unbind(existing)
} catch (_: Exception) {
}
}
provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
cameraSelector, cameraSelector,
preview preview
) )
previewUseCase = preview
} catch (e: Exception) { } catch (e: Exception) {
// Camera init failed // Camera init failed
} }

View File

@@ -69,7 +69,7 @@ fun Message.toChatMessage() = ChatMessage(
status = when (deliveryStatus) { status = when (deliveryStatus) {
DeliveryStatus.WAITING -> MessageStatus.SENDING DeliveryStatus.WAITING -> MessageStatus.SENDING
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR -> MessageStatus.SENT DeliveryStatus.ERROR -> MessageStatus.ERROR
DeliveryStatus.READ -> MessageStatus.READ DeliveryStatus.READ -> MessageStatus.READ
} }
) )

View File

@@ -0,0 +1,125 @@
package com.rosetta.messenger.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.dp
@Composable
fun AnimatedDownloadIndicator(
isActive: Boolean,
color: Color = Color.White,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isActive,
enter = fadeIn(animationSpec = tween(200)) + scaleIn(
initialScale = 0.6f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
targetScale = 0.6f,
animationSpec = tween(150)
),
modifier = modifier
) {
// Infinite rotation for the circular progress arc
val infiniteTransition = rememberInfiniteTransition(label = "download_rotation")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "download_rotation_angle"
)
// Pulsing arrow bounce
val arrowBounce by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "arrow_bounce"
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(24.dp)
) {
Canvas(modifier = Modifier.size(24.dp)) {
val centerX = size.width / 2
val centerY = size.height / 2
val radius = size.width / 2 - 2.dp.toPx()
val strokeWidth = 2.dp.toPx()
// 1) Rotating arc (circular progress indicator)
rotate(degrees = rotation, pivot = Offset(centerX, centerY)) {
drawArc(
color = color,
startAngle = 0f,
sweepAngle = 120f,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
topLeft = Offset(centerX - radius, centerY - radius),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2)
)
}
// 2) Arrow pointing down (download symbol)
val arrowOffset = arrowBounce * 1.5.dp.toPx()
val arrowStroke = 2.dp.toPx()
val arrowTop = centerY - 4.dp.toPx() + arrowOffset
val arrowBottom = centerY + 4.dp.toPx() + arrowOffset
val arrowWing = 3.dp.toPx()
// Vertical line of arrow
drawLine(
color = color,
start = Offset(centerX, arrowTop),
end = Offset(centerX, arrowBottom),
strokeWidth = arrowStroke,
cap = StrokeCap.Round
)
// Left wing of arrowhead
drawLine(
color = color,
start = Offset(centerX - arrowWing, arrowBottom - arrowWing),
end = Offset(centerX, arrowBottom),
strokeWidth = arrowStroke,
cap = StrokeCap.Round
)
// Right wing of arrowhead
drawLine(
color = color,
start = Offset(centerX + arrowWing, arrowBottom - arrowWing),
end = Offset(centerX, arrowBottom),
strokeWidth = arrowStroke,
cap = StrokeCap.Round
)
}
}
}
}

View File

@@ -88,6 +88,7 @@ fun SwipeBackContainer(
layer: Int = 1, layer: Int = 1,
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -243,7 +244,7 @@ fun SwipeBackContainer(
alpha = currentAlpha alpha = currentAlpha
} }
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) { .pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut, deferToChildren) {
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
@@ -268,12 +269,17 @@ fun SwipeBackContainer(
var totalDragY = 0f var totalDragY = 0f
var passedSlop = false var passedSlop = false
// Use Initial pass to intercept BEFORE children // deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off.
// deferToChildren=false (default): always use Initial pass
// to intercept before children (original behavior).
// Post-claim: always Initial to block children.
while (true) { while (true) {
val event = val pass =
awaitPointerEvent( if (startedSwipe || !deferToChildren)
PointerEventPass.Initial PointerEventPass.Initial
) else PointerEventPass.Main
val event = awaitPointerEvent(pass)
val change = val change =
event.changes.firstOrNull { event.changes.firstOrNull {
it.id == down.id it.id == down.id
@@ -289,6 +295,9 @@ fun SwipeBackContainer(
totalDragY += dragDelta.y totalDragY += dragDelta.y
if (!passedSlop) { if (!passedSlop) {
// Child (e.g. LazyRow) already consumed — let it handle
if (change.isConsumed) break
val totalDistance = val totalDistance =
kotlin.math.sqrt( kotlin.math.sqrt(
totalDragX * totalDragX *

View File

@@ -19,6 +19,8 @@ import androidx.compose.animation.core.animateFloatAsState
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.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -98,6 +100,7 @@ import compose.icons.tablericons.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@@ -183,9 +186,30 @@ fun OtherProfileScreen(
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) } var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { OtherProfileTab.entries.size }
)
// Tab click → animate pager
LaunchedEffect(selectedTab) {
val page = OtherProfileTab.entries.indexOf(selectedTab)
if (pagerState.currentPage != page) {
pagerState.animateScrollToPage(page)
}
}
// Pager swipe → update tab + control swipe-back
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
selectedTab = OtherProfileTab.entries[page]
// Swipe-back only on first tab (Media); on other tabs pager handles swipe
onSwipeBackEnabledChanged(page == 0 && !showImageViewer)
}
}
LaunchedEffect(showImageViewer) { LaunchedEffect(showImageViewer) {
onSwipeBackEnabledChanged(!showImageViewer) onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0)
} }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
@@ -711,25 +735,29 @@ fun OtherProfileScreen(
} }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// TAB CONTENT — inlined directly into LazyColumn items // TAB CONTENT — HorizontalPager for swipe between tabs
// for true virtualization (only visible items compose) // On first tab (Media) swipe-right triggers swipe-back;
// on other tabs swipe-right goes to the previous tab.
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
when (selectedTab) { item(key = "tab_pager") {
OtherProfileTab.MEDIA -> { HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) { page ->
when (page) {
// ── MEDIA ──
0 -> {
if (sharedContent.mediaPhotos.isEmpty()) { if (sharedContent.mediaPhotos.isEmpty()) {
item(key = "media_empty") {
OtherProfileEmptyState( OtherProfileEmptyState(
animationAssetPath = "lottie/saved.json", animationAssetPath = "lottie/saved.json",
title = "No shared media yet", title = "No shared media yet",
subtitle = "Photos from your chat will appear here.", subtitle = "Photos from your chat will appear here.",
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
} else { } else {
items( Column(modifier = Modifier.fillMaxWidth()) {
items = mediaIndexedRows, mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
key = { (idx, _) -> "media_row_$idx" }
) { (rowIdx, rowPhotos) ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(mediaSpacing) horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
@@ -737,11 +765,9 @@ fun OtherProfileScreen(
rowPhotos.forEachIndexed { colIdx, media -> rowPhotos.forEachIndexed { colIdx, media ->
val globalIndex = rowIdx * mediaColumns + colIdx val globalIndex = rowIdx * mediaColumns + colIdx
// Check cache first
val cachedBitmap = mediaBitmapStates[media.key] val cachedBitmap = mediaBitmapStates[media.key]
?: SharedMediaBitmapCache.get(media.key) ?: SharedMediaBitmapCache.get(media.key)
// Only launch decode for items not yet cached
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) { if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
LaunchedEffect(media.key) { LaunchedEffect(media.key) {
mediaDecodeSemaphore.withPermit { mediaDecodeSemaphore.withPermit {
@@ -762,7 +788,6 @@ fun OtherProfileScreen(
val model = remember(media.localUri, media.blob) { val model = remember(media.localUri, media.blob) {
resolveSharedMediaModel(media.localUri, media.blob) resolveSharedMediaModel(media.localUri, media.blob)
} }
// Decode blurred preview from base64 (small image ~4-16px)
val previewBitmap = remember(media.preview) { val previewBitmap = remember(media.preview) {
if (media.preview.isNotBlank()) { if (media.preview.isNotBlank()) {
runCatching { runCatching {
@@ -772,7 +797,6 @@ fun OtherProfileScreen(
} else null } else null
} }
val isLoaded = resolvedBitmap != null || model != null val isLoaded = resolvedBitmap != null || model != null
// Animate alpha for smooth fade-in
val imageAlpha by animateFloatAsState( val imageAlpha by animateFloatAsState(
targetValue = if (isLoaded) 1f else 0f, targetValue = if (isLoaded) 1f else 0f,
animationSpec = tween(300), animationSpec = tween(300),
@@ -791,7 +815,6 @@ fun OtherProfileScreen(
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Blurred preview placeholder (always shown initially)
if (previewBitmap != null) { if (previewBitmap != null) {
Image( Image(
bitmap = previewBitmap.asImageBitmap(), bitmap = previewBitmap.asImageBitmap(),
@@ -800,7 +823,6 @@ fun OtherProfileScreen(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else { } else {
// Fallback shimmer if no preview
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -810,7 +832,6 @@ fun OtherProfileScreen(
) )
) )
} }
// Full quality image fades in on top
if (resolvedBitmap != null) { if (resolvedBitmap != null) {
Image( Image(
bitmap = resolvedBitmap.asImageBitmap(), bitmap = resolvedBitmap.asImageBitmap(),
@@ -828,7 +849,6 @@ fun OtherProfileScreen(
} }
} }
} }
// Fill remaining cells in last incomplete row
repeat(mediaColumns - rowPhotos.size) { repeat(mediaColumns - rowPhotos.size) {
Spacer(modifier = Modifier.size(mediaCellSize)) Spacer(modifier = Modifier.size(mediaCellSize))
} }
@@ -836,22 +856,23 @@ fun OtherProfileScreen(
} }
} }
} }
OtherProfileTab.FILES -> { }
// ── FILES ──
1 -> {
if (sharedContent.files.isEmpty()) { if (sharedContent.files.isEmpty()) {
item(key = "files_empty") {
OtherProfileEmptyState( OtherProfileEmptyState(
animationAssetPath = "lottie/folder.json", animationAssetPath = "lottie/folder.json",
title = "No shared files", title = "No shared files",
subtitle = "Documents from this chat will appear here.", subtitle = "Documents from this chat will appear here.",
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
} else { } else {
val fileTextColor = if (isDarkTheme) Color.White else Color.Black val fileTextColor = if (isDarkTheme) Color.White else Color.Black
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file -> Column(modifier = Modifier.fillMaxWidth()) {
sharedContent.files.forEachIndexed { index, file ->
Column { Column {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -888,21 +909,22 @@ fun OtherProfileScreen(
} }
} }
} }
OtherProfileTab.LINKS -> { }
// ── LINKS ──
2 -> {
if (sharedContent.links.isEmpty()) { if (sharedContent.links.isEmpty()) {
item(key = "links_empty") {
OtherProfileEmptyState( OtherProfileEmptyState(
animationAssetPath = "lottie/earth.json", animationAssetPath = "lottie/earth.json",
title = "No shared links", title = "No shared links",
subtitle = "Links from your messages will appear here.", subtitle = "Links from your messages will appear here.",
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
} else { } else {
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link -> Column(modifier = Modifier.fillMaxWidth()) {
sharedContent.links.forEachIndexed { index, link ->
Column { Column {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -928,6 +950,9 @@ fun OtherProfileScreen(
} }
} }
} }
}
}
}
item(key = "bottom_spacer") { item(key = "bottom_spacer") {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@@ -1402,11 +1427,24 @@ private fun openUriInExternalApp(context: android.content.Context, uri: Uri, mim
private fun openSharedFile(context: android.content.Context, file: SharedFileItem): Boolean { private fun openSharedFile(context: android.content.Context, file: SharedFileItem): Boolean {
val mimeType = inferMimeType(file.fileName) val mimeType = inferMimeType(file.fileName)
// 1. Попробовать localUri
if (file.localUri.isNotBlank()) { if (file.localUri.isNotBlank()) {
val local = runCatching { Uri.parse(file.localUri) }.getOrNull() val local = runCatching { Uri.parse(file.localUri) }.getOrNull()
if (local != null && openUriInExternalApp(context, local, mimeType)) return true if (local != null && openUriInExternalApp(context, local, mimeType)) return true
} }
// 2. Проверить rosetta_downloads (файлы скачанные через CDN в чате)
val downloadsDir = File(context.filesDir, "rosetta_downloads")
val downloadedFile = File(downloadsDir, file.fileName)
if (downloadedFile.exists() && downloadedFile.length() > 0) {
val uri = runCatching {
FileProvider.getUriForFile(context, "${context.packageName}.provider", downloadedFile)
}.getOrNull()
if (uri != null && openUriInExternalApp(context, uri, mimeType)) return true
}
// 3. Попробовать blob (inline-файлы)
val bytes = decodeBlobPayload(file.blob) ?: return false val bytes = decodeBlobPayload(file.blob) ?: return false
val extension = file.fileName.substringAfterLast('.', "").lowercase(Locale.getDefault()) val extension = file.fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
val suffix = if (extension.isBlank()) ".bin" else ".$extension" val suffix = if (extension.isBlank()) ".bin" else ".$extension"

View File

@@ -5,11 +5,14 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -18,7 +21,6 @@ import androidx.compose.material3.*
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -37,7 +39,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@@ -64,8 +68,10 @@ private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
fun ThemeScreen( fun ThemeScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
currentThemeMode: String, currentThemeMode: String,
currentWallpaperId: String,
onBack: () -> Unit, onBack: () -> Unit,
onThemeModeChange: (String) -> Unit onThemeModeChange: (String) -> Unit,
onWallpaperChange: (String) -> Unit
) { ) {
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
@@ -78,7 +84,6 @@ fun ThemeScreen(
} }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -95,11 +100,16 @@ fun ThemeScreen(
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) } var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) } var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) } var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
var wallpaperId by remember { mutableStateOf(currentWallpaperId) }
LaunchedEffect(currentThemeMode) { LaunchedEffect(currentThemeMode) {
themeMode = currentThemeMode themeMode = currentThemeMode
} }
LaunchedEffect(currentWallpaperId) {
wallpaperId = currentWallpaperId
}
fun resolveThemeIsDark(mode: String): Boolean = fun resolveThemeIsDark(mode: String): Boolean =
when (mode) { when (mode) {
"dark" -> true "dark" -> true
@@ -214,7 +224,7 @@ fun ThemeScreen(
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
// CHAT PREVIEW - Message bubbles like in real chat // CHAT PREVIEW - Message bubbles like in real chat
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
ChatPreview(isDarkTheme = isDarkTheme) ChatPreview(isDarkTheme = isDarkTheme, wallpaperId = wallpaperId)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -266,7 +276,27 @@ fun ThemeScreen(
secondaryTextColor = secondaryTextColor secondaryTextColor = secondaryTextColor
) )
Spacer(modifier = Modifier.height(32.dp)) TelegramSectionHeader("Chat Wallpaper", secondaryTextColor)
WallpaperSelectorRow(
isDarkTheme = isDarkTheme,
selectedWallpaperId = wallpaperId,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onWallpaperSelected = { selectedId ->
if (selectedId != wallpaperId) {
wallpaperId = selectedId
onWallpaperChange(selectedId)
}
}
)
TelegramInfoText(
text = "Selected wallpaper is used for chat backgrounds.",
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(24.dp))
} }
} }
@@ -420,16 +450,111 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
) )
} }
@Composable
private fun WallpaperSelectorRow(
isDarkTheme: Boolean,
selectedWallpaperId: String,
textColor: Color,
secondaryTextColor: Color,
onWallpaperSelected: (String) -> Unit
) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "none") {
WallpaperSelectorItem(
title = "No wallpaper",
wallpaperResId = null,
isSelected = selectedWallpaperId.isBlank(),
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onClick = { onWallpaperSelected("") }
)
}
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
WallpaperSelectorItem(
title = wallpaper.name,
wallpaperResId = wallpaper.drawableRes,
isSelected = wallpaper.id == selectedWallpaperId,
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onClick = { onWallpaperSelected(wallpaper.id) }
)
}
}
}
@Composable
private fun WallpaperSelectorItem(
title: String,
wallpaperResId: Int?,
isSelected: Boolean,
isDarkTheme: Boolean,
textColor: Color,
secondaryTextColor: Color,
onClick: () -> Unit
) {
val borderColor = if (isSelected) Color(0xFF007AFF) else secondaryTextColor.copy(alpha = 0.35f)
Column(
modifier =
Modifier.width(118.dp).clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth().height(76.dp),
shape = RoundedCornerShape(10.dp),
color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor)
) {
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7))
)
}
}
Text(
text = title,
fontSize = 12.sp,
color = if (isSelected) textColor else secondaryTextColor,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1
)
}
}
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// 💬 CHAT PREVIEW - Real message bubbles preview // 💬 CHAT PREVIEW - Real message bubbles preview
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@Composable @Composable
private fun ChatPreview(isDarkTheme: Boolean) { private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
val wallpaperResId = remember(wallpaperId) { ThemeWallpapers.drawableResOrNull(wallpaperId) }
val hasWallpaper = wallpaperResId != null
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0) val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
// Message colors matching real ChatDetailScreen // Message colors matching real ChatDetailScreen
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) val otherBubbleColor =
if (hasWallpaper) {
if (isDarkTheme) Color(0xFF2C2E33) else Color.White
} else {
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
val myTextColor = Color.White // White text on blue bubble val myTextColor = Color.White // White text on blue bubble
val otherTextColor = if (isDarkTheme) Color.White else Color.Black val otherTextColor = if (isDarkTheme) Color.White else Color.Black
val myTimeColor = Color.White // White time on blue bubble (matches real chat) val myTimeColor = Color.White // White time on blue bubble (matches real chat)
@@ -444,10 +569,18 @@ private fun ChatPreview(isDarkTheme: Boolean) {
color = chatBgColor, color = chatBgColor,
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = "Chat wallpaper preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(12.dp),
.fillMaxSize()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Incoming message // Incoming message
@@ -497,6 +630,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
} }
} }
} }
}
} }
@Composable @Composable

View File

@@ -0,0 +1,33 @@
package com.rosetta.messenger.ui.settings
import androidx.annotation.DrawableRes
import com.rosetta.messenger.R
data class ThemeWallpaper(
val id: String,
val name: String,
@DrawableRes val drawableRes: Int
)
object ThemeWallpapers {
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
val all: List<ThemeWallpaper> =
listOf(
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3),
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4),
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5),
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6),
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7),
ThemeWallpaper(id = "back_8", name = "Wallpaper 6", drawableRes = R.drawable.wallpaper_back_8),
ThemeWallpaper(id = "back_9", name = "Wallpaper 7", drawableRes = R.drawable.wallpaper_back_9),
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10),
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11),
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1),
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2)
)
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
@DrawableRes
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B