Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и UX-фиксы
Что вошло:\n- Добавлен полноценный Messages-tab в SearchScreen: поиск по тексту сообщений по всей базе, батчевый проход, параллельная дешифровка, кеш расшифровки, подсветка совпадений, сниппеты и быстрый переход в нужный диалог.\n- В Chats-tab добавлены алиасы для Saved Messages (saved/saved messages/избранное/сохраненные и др.), чтобы чат открывался по текстовому поиску даже без точного username/public key.\n- Для search-бэкенда расширен DAO: getAllMessagesPaged() для постраничного обхода сообщений аккаунта.\n- Исправлена логика клика по @тэгам в сообщениях:\n - переход теперь ведет сразу в чат пользователя (а не в профиль);\n - добавлен fallback-резолв username -> user через локальный диалог, кеш протокола и PacketSearch;\n - добавлен DAO getDialogByUsername() (регистронезависимо и с игнором @).\n- Усилена обработка PacketSearch в ProtocolManager:\n - добавлена очередь ожидания pendingSearchQueries;\n - нормализация query (без @, lowercase);\n - устойчивый матч ответов сервера (raw/normalized/by username);\n - добавлены методы getCachedUserByUsername() и searchUsers().\n- Исправлен конфликт тачей между ClickableSpan и bubble-menu:\n - в AppleEmojiText/AppleEmojiTextView добавлен callback начала тапа по span;\n - улучшен hit-test по span (включая пограничные offset/layout fallback);\n - suppress performClick на span-тапах;\n - в MessageBubble добавлен тайм-guard, чтобы tap по span не открывал context menu.\n- Стабилизирован verified-бейдж в заголовке чата: агрегируется из переданного user, кеша протокола, локальной БД и серверного resolve; отображается консистентно в личных чатах.\n- Улучшен пустой экран Saved Messages при обоях: добавлена аккуратная подложка/бордер и выровненный текст, чтобы контент оставался читабельным на любом фоне.\n- Реализована автосвязка обоев между светлой/темной темами:\n - добавлены pairGroup и mapToTheme/resolveWallpaperForTheme в ThemeWallpapers;\n - добавлены отдельные prefs-ключи для light/dark wallpaper;\n - MainActivity теперь автоматически подбирает и сохраняет обои под активную тему и сохраняет выбор по теме.\n- Биометрия: если на устройстве нет hardware fingerprint, экран включения биометрии не показывается (и доступность возвращает NotAvailable).\n- Небольшие UI-фиксы: поправлено позиционирование галочки в сайдбаре.\n- Техдолг: удалена неиспользуемая зависимость jsoup из build.gradle.
This commit is contained in:
@@ -129,9 +129,6 @@ dependencies {
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
|
||||
|
||||
// Jsoup for HTML parsing (Link Preview OG tags)
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
|
||||
// uCrop for image cropping
|
||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import com.rosetta.messenger.ui.settings.OtherProfileScreen
|
||||
import com.rosetta.messenger.ui.settings.ProfileScreen
|
||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||
import com.rosetta.messenger.ui.settings.ThemeScreen
|
||||
import com.rosetta.messenger.ui.settings.ThemeWallpapers
|
||||
import com.rosetta.messenger.ui.settings.UpdatesScreen
|
||||
import com.rosetta.messenger.ui.splash.SplashScreen
|
||||
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
||||
@@ -743,6 +744,8 @@ fun MainScreen(
|
||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||
.collectAsState(initial = "avatar")
|
||||
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
|
||||
val chatWallpaperIdLight by prefsManager.chatWallpaperIdLight.collectAsState(initial = "")
|
||||
val chatWallpaperIdDark by prefsManager.chatWallpaperIdDark.collectAsState(initial = "")
|
||||
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||
|
||||
// AvatarRepository для работы с аватарами
|
||||
@@ -763,6 +766,29 @@ fun MainScreen(
|
||||
// Coroutine scope for profile updates
|
||||
val mainScreenScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(isDarkTheme, chatWallpaperId, chatWallpaperIdLight, chatWallpaperIdDark) {
|
||||
val targetWallpaperId =
|
||||
ThemeWallpapers.resolveWallpaperForTheme(
|
||||
currentWallpaperId = chatWallpaperId,
|
||||
isDarkTheme = isDarkTheme,
|
||||
darkThemeWallpaperId = chatWallpaperIdDark,
|
||||
lightThemeWallpaperId = chatWallpaperIdLight
|
||||
)
|
||||
|
||||
if (targetWallpaperId != chatWallpaperId) {
|
||||
prefsManager.setChatWallpaperId(targetWallpaperId)
|
||||
}
|
||||
|
||||
val currentThemeStored =
|
||||
if (isDarkTheme) chatWallpaperIdDark else chatWallpaperIdLight
|
||||
if (currentThemeStored != targetWallpaperId) {
|
||||
prefsManager.setChatWallpaperIdForTheme(
|
||||
isDarkTheme = isDarkTheme,
|
||||
value = targetWallpaperId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Простая навигация с swipe back
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
|
||||
@@ -971,7 +997,13 @@ fun MainScreen(
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
onWallpaperChange = { wallpaperId ->
|
||||
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
|
||||
mainScreenScope.launch {
|
||||
prefsManager.setChatWallpaperIdForTheme(
|
||||
isDarkTheme = isDarkTheme,
|
||||
value = wallpaperId
|
||||
)
|
||||
prefsManager.setChatWallpaperId(wallpaperId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1280,6 +1312,16 @@ fun MainScreen(
|
||||
}
|
||||
val biometricAccountManager = remember { AccountManager(context) }
|
||||
val activity = context as? FragmentActivity
|
||||
val isFingerprintSupported = remember {
|
||||
biometricManager.isFingerprintHardwareAvailable()
|
||||
}
|
||||
|
||||
if (!isFingerprintSupported) {
|
||||
LaunchedEffect(Unit) {
|
||||
navStack = navStack.filterNot { it is Screen.Biometric }
|
||||
}
|
||||
return@SwipeBackContainer
|
||||
}
|
||||
|
||||
BiometricEnableScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.biometric
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
@@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) {
|
||||
* Проверяет доступность STRONG биометрической аутентификации
|
||||
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
|
||||
*/
|
||||
fun isFingerprintHardwareAvailable(): Boolean {
|
||||
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||
}
|
||||
|
||||
fun isBiometricAvailable(): BiometricAvailability {
|
||||
if (!isFingerprintHardwareAvailable()) {
|
||||
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
|
||||
}
|
||||
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
|
||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
||||
|
||||
@@ -1592,7 +1592,7 @@ object MessageCrypto {
|
||||
// Reset bounds to default after first continuation
|
||||
lowerBoundary = 0x80
|
||||
upperBoundary = 0xBF
|
||||
// commit
|
||||
// test// commit
|
||||
if (bytesSeen == bytesNeeded) {
|
||||
// Sequence complete — emit code point
|
||||
if (codePoint <= 0xFFFF) {
|
||||
|
||||
@@ -28,6 +28,8 @@ class PreferencesManager(private val context: Context) {
|
||||
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
||||
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
|
||||
val CHAT_WALLPAPER_ID_LIGHT = stringPreferencesKey("chat_wallpaper_id_light")
|
||||
val CHAT_WALLPAPER_ID_DARK = stringPreferencesKey("chat_wallpaper_id_dark")
|
||||
|
||||
// Notifications
|
||||
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||
@@ -104,10 +106,21 @@ class PreferencesManager(private val context: Context) {
|
||||
val chatWallpaperId: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
|
||||
|
||||
val chatWallpaperIdLight: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_LIGHT] ?: "" }
|
||||
|
||||
val chatWallpaperIdDark: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_DARK] ?: "" }
|
||||
|
||||
suspend fun setChatWallpaperId(value: String) {
|
||||
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
|
||||
}
|
||||
|
||||
suspend fun setChatWallpaperIdForTheme(isDarkTheme: Boolean, value: String) {
|
||||
val key = if (isDarkTheme) CHAT_WALLPAPER_ID_DARK else CHAT_WALLPAPER_ID_LIGHT
|
||||
context.dataStore.edit { preferences -> preferences[key] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🔔 NOTIFICATIONS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -558,6 +558,18 @@ interface MessageDao {
|
||||
"""
|
||||
)
|
||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
|
||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND plain_message != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
}
|
||||
|
||||
/** DAO для работы с диалогами */
|
||||
@@ -658,6 +670,18 @@ interface DialogDao {
|
||||
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
|
||||
suspend fun getDialog(account: String, opponentKey: String): DialogEntity?
|
||||
|
||||
/** Найти direct-диалог по username собеседника (без учета регистра и '@'). */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND opponent_key NOT LIKE '#group:%'
|
||||
AND LOWER(REPLACE(TRIM(opponent_username), '@', '')) = LOWER(REPLACE(TRIM(:username), '@', ''))
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getDialogByUsername(account: String, username: String): DialogEntity?
|
||||
|
||||
/** Обновить последнее сообщение */
|
||||
@Query(
|
||||
"""
|
||||
|
||||
@@ -80,6 +80,12 @@ object ProtocolManager {
|
||||
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
||||
// Pending resolves: publicKey → list of continuations waiting for the result
|
||||
private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
|
||||
// Pending search requests: query(username/publicKey fragment) → waiting continuations
|
||||
private val pendingSearchQueries =
|
||||
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>>()
|
||||
|
||||
private fun normalizeSearchQuery(value: String): String =
|
||||
value.trim().removePrefix("@").lowercase(Locale.ROOT)
|
||||
|
||||
// UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS.
|
||||
private var uiLogsEnabled = true
|
||||
@@ -473,6 +479,56 @@ object ProtocolManager {
|
||||
try { cont.resume(null) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume pending username/query searches.
|
||||
// Server may return query in different case/format, so match robustly.
|
||||
if (searchPacket.search.isNotEmpty()) {
|
||||
val rawQuery = searchPacket.search.trim()
|
||||
val normalizedQuery = normalizeSearchQuery(rawQuery)
|
||||
val continuations = LinkedHashSet<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>()
|
||||
|
||||
fun collectByKey(key: String) {
|
||||
if (key.isEmpty()) return
|
||||
pendingSearchQueries.remove(key)?.let { continuations.addAll(it) }
|
||||
}
|
||||
|
||||
collectByKey(rawQuery)
|
||||
if (normalizedQuery.isNotEmpty() && normalizedQuery != rawQuery) {
|
||||
collectByKey(normalizedQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
val matchedByQuery =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
pendingKey.equals(rawQuery, ignoreCase = true) ||
|
||||
normalizeSearchQuery(pendingKey) == normalizedQuery
|
||||
}
|
||||
if (matchedByQuery != null) collectByKey(matchedByQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty() && searchPacket.users.isNotEmpty()) {
|
||||
val responseUsernames =
|
||||
searchPacket.users
|
||||
.map { normalizeSearchQuery(it.username) }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
if (responseUsernames.isNotEmpty()) {
|
||||
val matchedByUsers =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
val normalizedPending = normalizeSearchQuery(pendingKey)
|
||||
normalizedPending.isNotEmpty() &&
|
||||
responseUsernames.contains(normalizedPending)
|
||||
}
|
||||
if (matchedByUsers != null) collectByKey(matchedByUsers)
|
||||
}
|
||||
}
|
||||
|
||||
continuations.forEach { cont ->
|
||||
try {
|
||||
cont.resume(searchPacket.users)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 Обработчик транспортного сервера (0x0F)
|
||||
@@ -973,6 +1029,18 @@ object ProtocolManager {
|
||||
return userInfoCache[publicKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Get cached user by username (no network request).
|
||||
* Username compare is case-insensitive and ignores '@'.
|
||||
*/
|
||||
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||
val normalizedUsername = normalizeSearchQuery(username)
|
||||
if (normalizedUsername.isEmpty()) return null
|
||||
return userInfoCache.values.firstOrNull { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedUsername
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Resolve publicKey → full SearchUser (with server request if needed)
|
||||
*/
|
||||
@@ -1009,6 +1077,52 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Search users by query (usually username without '@').
|
||||
* Returns raw PacketSearch users list for the exact query.
|
||||
*/
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||
val normalizedQuery = normalizeSearchQuery(query)
|
||||
if (normalizedQuery.isEmpty()) return emptyList()
|
||||
|
||||
val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null }
|
||||
?: return emptyList()
|
||||
|
||||
val cachedMatches =
|
||||
userInfoCache.values.filter { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedQuery && cached.publicKey.isNotBlank()
|
||||
}
|
||||
if (cachedMatches.isNotEmpty()) {
|
||||
return cachedMatches.distinctBy { it.publicKey }
|
||||
}
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingSearchQueries
|
||||
.getOrPut(normalizedQuery) { mutableListOf() }
|
||||
.add(cont)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
pendingSearchQueries[normalizedQuery]?.remove(cont)
|
||||
if (pendingSearchQueries[normalizedQuery]?.isEmpty() == true) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = normalizedQuery
|
||||
}
|
||||
send(packet)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending device login request.
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -70,6 +71,7 @@ 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.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -90,6 +92,7 @@ import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||
@@ -411,6 +414,57 @@ fun ChatDetailScreen(
|
||||
val chatTitle =
|
||||
if (isSavedMessages) "Saved Messages"
|
||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
||||
var chatHeaderVerified by
|
||||
remember(user.publicKey, user.verified) {
|
||||
mutableIntStateOf(user.verified.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
user.publicKey,
|
||||
user.verified,
|
||||
currentUserPublicKey,
|
||||
isSavedMessages,
|
||||
isGroupChat
|
||||
) {
|
||||
chatHeaderVerified = user.verified.coerceAtLeast(0)
|
||||
|
||||
if (isSavedMessages || isGroupChat || currentUserPublicKey.isBlank()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val normalizedPublicKey = user.publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
|
||||
|
||||
val cachedVerified =
|
||||
ProtocolManager.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0
|
||||
if (cachedVerified > chatHeaderVerified) {
|
||||
chatHeaderVerified = cachedVerified
|
||||
}
|
||||
|
||||
val localVerified =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
database
|
||||
.dialogDao()
|
||||
.getDialog(currentUserPublicKey, normalizedPublicKey)
|
||||
?.verified ?: 0
|
||||
}
|
||||
.getOrDefault(0)
|
||||
}
|
||||
if (localVerified > chatHeaderVerified) {
|
||||
chatHeaderVerified = localVerified
|
||||
}
|
||||
|
||||
val resolvedVerified =
|
||||
runCatching {
|
||||
viewModel.resolveUserForProfile(normalizedPublicKey)?.verified
|
||||
?: 0
|
||||
}
|
||||
.getOrDefault(0)
|
||||
if (resolvedVerified > chatHeaderVerified) {
|
||||
chatHeaderVerified = resolvedVerified
|
||||
}
|
||||
}
|
||||
|
||||
val openDialogInfo: () -> Unit = {
|
||||
hideInputOverlays()
|
||||
@@ -1725,7 +1779,7 @@ fun ChatDetailScreen(
|
||||
)
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
(user.verified >
|
||||
(chatHeaderVerified >
|
||||
0 || isRosettaOfficial)
|
||||
) {
|
||||
Spacer(
|
||||
@@ -1736,7 +1790,7 @@ fun ChatDetailScreen(
|
||||
)
|
||||
VerifiedBadge(
|
||||
verified =
|
||||
if (user.verified > 0) user.verified else 1,
|
||||
if (chatHeaderVerified > 0) chatHeaderVerified else 1,
|
||||
size =
|
||||
16,
|
||||
isDarkTheme =
|
||||
@@ -2526,102 +2580,184 @@ fun ChatDetailScreen(
|
||||
verticalArrangement =
|
||||
Arrangement.Center
|
||||
) {
|
||||
if (isSavedMessages) {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.saved
|
||||
)
|
||||
val showSavedMessagesBackdrop =
|
||||
isSavedMessages &&
|
||||
hasChatWallpaper
|
||||
val savedMessagesBackdropShape =
|
||||
RoundedCornerShape(
|
||||
22.dp
|
||||
)
|
||||
val savedMessagesBackdropColor =
|
||||
if (isDarkTheme)
|
||||
Color(
|
||||
0xB3212121
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
else
|
||||
Color(
|
||||
0xB32A2A2A
|
||||
)
|
||||
val contentModifier =
|
||||
if (
|
||||
showSavedMessagesBackdrop
|
||||
) {
|
||||
Modifier
|
||||
.widthIn(
|
||||
max =
|
||||
340.dp
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color =
|
||||
savedMessagesBackdropColor,
|
||||
shape =
|
||||
savedMessagesBackdropShape
|
||||
)
|
||||
.border(
|
||||
width =
|
||||
1.dp,
|
||||
color =
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.12f
|
||||
),
|
||||
shape =
|
||||
savedMessagesBackdropShape
|
||||
)
|
||||
.padding(
|
||||
horizontal =
|
||||
22.dp,
|
||||
vertical =
|
||||
18.dp
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
contentModifier,
|
||||
horizontalAlignment =
|
||||
Alignment
|
||||
.CenterHorizontally
|
||||
) {
|
||||
val emptyStateTextAlign =
|
||||
if (isSavedMessages)
|
||||
TextAlign.Center
|
||||
else
|
||||
TextAlign.Start
|
||||
val emptyStateTextModifier =
|
||||
if (isSavedMessages)
|
||||
Modifier.fillMaxWidth()
|
||||
else
|
||||
Modifier
|
||||
if (isSavedMessages) {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.saved
|
||||
)
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
)
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
} else {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.speech
|
||||
)
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
Modifier.height(
|
||||
16.dp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.speech
|
||||
)
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Save messages here for quick access"
|
||||
else
|
||||
"No messages yet",
|
||||
fontSize =
|
||||
16.sp,
|
||||
color =
|
||||
dateHeaderTextColor,
|
||||
fontWeight =
|
||||
FontWeight
|
||||
.Medium,
|
||||
textAlign =
|
||||
emptyStateTextAlign,
|
||||
modifier =
|
||||
emptyStateTextModifier
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
8.dp
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Forward messages here or send notes to yourself"
|
||||
else
|
||||
"Send a message to start the conversation",
|
||||
fontSize =
|
||||
14.sp,
|
||||
color =
|
||||
dateHeaderTextColor
|
||||
.copy(
|
||||
alpha =
|
||||
0.7f
|
||||
),
|
||||
textAlign =
|
||||
emptyStateTextAlign,
|
||||
modifier =
|
||||
emptyStateTextModifier
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
16.dp
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Save messages here for quick access"
|
||||
else
|
||||
"No messages yet",
|
||||
fontSize = 16.sp,
|
||||
color =
|
||||
dateHeaderTextColor,
|
||||
fontWeight =
|
||||
FontWeight
|
||||
.Medium
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
8.dp
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Forward messages here or send notes to yourself"
|
||||
else
|
||||
"Send a message to start the conversation",
|
||||
fontSize = 14.sp,
|
||||
color =
|
||||
dateHeaderTextColor
|
||||
.copy(
|
||||
alpha =
|
||||
0.7f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Есть сообщения
|
||||
@@ -2981,6 +3117,8 @@ fun ChatDetailScreen(
|
||||
val normalizedUsername =
|
||||
username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||
if (normalizedUsername.isBlank()) return@MessageBubble
|
||||
// Mention tap should not trigger bubble context-menu tap.
|
||||
suppressTapAfterLongPress(selectionKey)
|
||||
scope.launch {
|
||||
val normalizedCurrentUsername =
|
||||
currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||
@@ -3006,12 +3144,21 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPublicKey.isBlank()) return@launch
|
||||
if (targetPublicKey.isBlank()) {
|
||||
val resolvedByUsername =
|
||||
viewModel.resolveUserByUsername(normalizedUsername)
|
||||
if (resolvedByUsername != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onNavigateToChat(resolvedByUsername)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (targetPublicKey.equals(currentUserPublicKey.trim(), ignoreCase = true)) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onUserProfileClick(
|
||||
onNavigateToChat(
|
||||
SearchUser(
|
||||
title = currentUserName.ifBlank { "You" },
|
||||
username = currentUserUsername.trim().trimStart('@'),
|
||||
@@ -3027,7 +3174,7 @@ fun ChatDetailScreen(
|
||||
if (resolvedUser != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onUserProfileClick(resolvedUser)
|
||||
onNavigateToChat(resolvedUser)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.*
|
||||
@@ -2290,6 +2291,44 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve @username to SearchUser using PacketSearch.
|
||||
* Used for clickable mentions inside chat messages.
|
||||
*/
|
||||
suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? {
|
||||
val normalized = username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||
if (normalized.isBlank()) return null
|
||||
|
||||
// 1) Local DB first: in private chats this gives instant/stable navigation.
|
||||
val account = myPublicKey?.trim().orEmpty()
|
||||
if (account.isNotBlank()) {
|
||||
val localDialog =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { dialogDao.getDialogByUsername(account, normalized) }.getOrNull()
|
||||
}
|
||||
if (localDialog != null && localDialog.opponentKey.isNotBlank()) {
|
||||
return SearchUser(
|
||||
title = localDialog.opponentTitle.ifBlank { normalized },
|
||||
username = localDialog.opponentUsername,
|
||||
publicKey = localDialog.opponentKey,
|
||||
verified = localDialog.verified,
|
||||
online = localDialog.isOnline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) In-memory protocol cache.
|
||||
ProtocolManager.getCachedUserByUsername(normalized)?.let { return it }
|
||||
|
||||
// 3) Server search fallback.
|
||||
val results = ProtocolManager.searchUsers(normalized, timeoutMs)
|
||||
if (results.isEmpty()) return null
|
||||
|
||||
return results.firstOrNull {
|
||||
it.username.trim().trimStart('@').lowercase(Locale.ROOT) == normalized
|
||||
} ?: results.firstOrNull()
|
||||
}
|
||||
|
||||
/** 🔥 Повторить отправку сообщения (для ошибки) */
|
||||
fun retryMessage(message: ChatMessage) {
|
||||
// Удаляем старое сообщение
|
||||
|
||||
@@ -1061,7 +1061,16 @@ fun ChatsListScreen(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(8.dp)
|
||||
modifier =
|
||||
Modifier.size(
|
||||
8.dp
|
||||
)
|
||||
.offset(
|
||||
x =
|
||||
0.3.dp,
|
||||
y =
|
||||
0.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
@@ -70,6 +73,11 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
|
||||
// Primary Blue color
|
||||
private val PrimaryBlue = Color(0xFF54A9EB)
|
||||
@@ -77,6 +85,7 @@ private val PrimaryBlue = Color(0xFF54A9EB)
|
||||
/** Вкладки поиска как в Telegram */
|
||||
private enum class SearchTab(val title: String) {
|
||||
CHATS("Chats"),
|
||||
MESSAGES("Messages"),
|
||||
MEDIA("Media"),
|
||||
DOWNLOADS("Downloads"),
|
||||
FILES("Files")
|
||||
@@ -382,6 +391,18 @@ fun SearchScreen(
|
||||
onUserSelect = onUserSelect
|
||||
)
|
||||
}
|
||||
SearchTab.MESSAGES -> {
|
||||
MessagesTabContent(
|
||||
searchQuery = searchQuery,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
avatarRepository = avatarRepository,
|
||||
searchLottieComposition = searchLottieComposition,
|
||||
onUserSelect = onUserSelect
|
||||
)
|
||||
}
|
||||
SearchTab.MEDIA -> {
|
||||
MediaTabContent(
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
@@ -639,14 +660,35 @@ private fun ChatsTabContent(
|
||||
} else {
|
||||
// ═══ Search results ═══
|
||||
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
|
||||
val compactQuery = normalizedQuery.replace(Regex("\\s+"), " ").trim()
|
||||
val normalizedPublicKey = currentUserPublicKey.lowercase()
|
||||
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
|
||||
val normalizedName = ownAccountName.trim().lowercase()
|
||||
val hasValidOwnName =
|
||||
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName)
|
||||
val savedMessagesAliases =
|
||||
listOf(
|
||||
"saved",
|
||||
"saved message",
|
||||
"saved messages",
|
||||
"savedmessages",
|
||||
"bookmarks",
|
||||
"bookmark",
|
||||
"избранное",
|
||||
"сохраненное",
|
||||
"сохранённое",
|
||||
"сохраненные",
|
||||
"сохранённые"
|
||||
)
|
||||
val isSavedAliasSearch =
|
||||
compactQuery.length >= 3 &&
|
||||
savedMessagesAliases.any { alias ->
|
||||
alias.startsWith(compactQuery) || compactQuery.startsWith(alias)
|
||||
}
|
||||
val isSavedMessagesSearch =
|
||||
normalizedQuery.isNotEmpty() &&
|
||||
(normalizedPublicKey == normalizedQuery ||
|
||||
(isSavedAliasSearch ||
|
||||
normalizedPublicKey == normalizedQuery ||
|
||||
normalizedPublicKey.startsWith(normalizedQuery) ||
|
||||
normalizedPublicKey.take(8) == normalizedQuery ||
|
||||
normalizedPublicKey.takeLast(8) == normalizedQuery ||
|
||||
@@ -916,6 +958,422 @@ private fun SearchSkeleton(isDarkTheme: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 💬 MESSAGES TAB — search through decrypted message text
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** A single message search result */
|
||||
private data class MessageSearchResult(
|
||||
val messageId: String,
|
||||
val dialogKey: String,
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
val opponentUsername: String,
|
||||
val plainText: String,
|
||||
val timestamp: Long,
|
||||
val fromMe: Boolean,
|
||||
val verified: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* Optimized message search: loads messages in batches, decrypts plainMessage
|
||||
* fields in parallel (Semaphore-limited), filters client-side, and caches
|
||||
* decrypted text to avoid re-decryption on subsequent queries.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessagesTabContent(
|
||||
searchQuery: String,
|
||||
currentUserPublicKey: String,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
avatarRepository: AvatarRepository?,
|
||||
searchLottieComposition: com.airbnb.lottie.LottieComposition?,
|
||||
onUserSelect: (SearchUser) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var results by remember { mutableStateOf<List<MessageSearchResult>>(emptyList()) }
|
||||
var isSearching by remember { mutableStateOf(false) }
|
||||
val dividerColor = remember(isDarkTheme) {
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
}
|
||||
|
||||
// Persistent decryption cache: messageId → plaintext (survives re-queries)
|
||||
val decryptCache = remember { ConcurrentHashMap<String, String>(512) }
|
||||
// Cache for dialog metadata: opponentKey → (title, username, verified)
|
||||
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() }
|
||||
|
||||
val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) }
|
||||
|
||||
// Debounced search: waits 600ms after typing stops, then searches
|
||||
LaunchedEffect(searchQuery, currentUserPublicKey) {
|
||||
results = emptyList()
|
||||
if (searchQuery.length < 2 || currentUserPublicKey.isBlank()) {
|
||||
isSearching = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
isSearching = true
|
||||
// Debounce
|
||||
kotlinx.coroutines.delay(600)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val db = RosettaDatabase.getDatabase(context)
|
||||
val repo = com.rosetta.messenger.data.MessageRepository.getInstance(context)
|
||||
val privateKey = repo.getCurrentPrivateKey().orEmpty()
|
||||
if (privateKey.isBlank()) {
|
||||
isSearching = false
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Pre-warm PBKDF2 cache for this password
|
||||
CryptoManager.getPbkdf2Key(privateKey)
|
||||
|
||||
// Load dialog metadata once
|
||||
if (dialogCache.isEmpty()) {
|
||||
val dialogs = db.dialogDao().getDialogsPaged(currentUserPublicKey, 500, 0)
|
||||
for (d in dialogs) {
|
||||
dialogCache[d.opponentKey] = Triple(d.opponentTitle, d.opponentUsername, d.verified)
|
||||
}
|
||||
}
|
||||
|
||||
val queryLower = searchQuery.trim().lowercase()
|
||||
val matched = mutableListOf<MessageSearchResult>()
|
||||
val semaphore = Semaphore(4)
|
||||
val batchSize = 200
|
||||
var offset = 0
|
||||
val maxMessages = 5000 // Safety cap
|
||||
val maxResults = 50 // Don't return more than 50 matches
|
||||
|
||||
while (offset < maxMessages && matched.size < maxResults) {
|
||||
val batch = db.messageDao().getAllMessagesPaged(
|
||||
currentUserPublicKey, batchSize, offset
|
||||
)
|
||||
if (batch.isEmpty()) break
|
||||
|
||||
// Decrypt in parallel, filter by query
|
||||
val batchResults = kotlinx.coroutines.coroutineScope {
|
||||
batch.chunked(20).flatMap { chunk ->
|
||||
chunk.map { msg ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
val cached = decryptCache[msg.messageId]
|
||||
val plain = if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val decrypted = try {
|
||||
CryptoManager.decryptWithPassword(
|
||||
msg.plainMessage, privateKey
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
if (!decrypted.isNullOrBlank()) {
|
||||
decryptCache[msg.messageId] = decrypted
|
||||
}
|
||||
decrypted
|
||||
}
|
||||
|
||||
if (!plain.isNullOrBlank() && plain.lowercase().contains(queryLower)) {
|
||||
val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey
|
||||
val normalized = opponent.trim()
|
||||
val meta = dialogCache[normalized]
|
||||
MessageSearchResult(
|
||||
messageId = msg.messageId,
|
||||
dialogKey = msg.dialogKey,
|
||||
opponentKey = normalized,
|
||||
opponentTitle = meta?.first.orEmpty(),
|
||||
opponentUsername = meta?.second.orEmpty(),
|
||||
plainText = plain,
|
||||
timestamp = msg.timestamp,
|
||||
fromMe = msg.fromMe == 1,
|
||||
verified = meta?.third ?: 0
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
matched.addAll(batchResults)
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
results = matched.take(maxResults)
|
||||
} catch (_: Exception) {
|
||||
results = emptyList()
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
searchQuery.length < 2 -> {
|
||||
// Idle state — prompt to type
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (searchLottieComposition != null) {
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = searchLottieComposition,
|
||||
iterations = 1,
|
||||
isPlaying = true,
|
||||
restartOnPlay = false
|
||||
)
|
||||
LottieAnimation(
|
||||
composition = searchLottieComposition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Search in messages",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Type at least 2 characters",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
isSearching -> {
|
||||
// Loading state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = PrimaryBlue,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Searching messages...",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
results.isEmpty() -> {
|
||||
// No results
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.search_files_filled),
|
||||
contentDescription = null,
|
||||
tint = secondaryTextColor.copy(alpha = 0.4f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No messages found",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor.copy(alpha = 0.8f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Try a different search term",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
items(results, key = { it.messageId }) { result ->
|
||||
MessageSearchResultItem(
|
||||
result = result,
|
||||
searchQuery = searchQuery,
|
||||
dateFormat = dateFormat,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
avatarRepository = avatarRepository,
|
||||
onClick = {
|
||||
val user = SearchUser(
|
||||
title = result.opponentTitle,
|
||||
username = result.opponentUsername,
|
||||
publicKey = result.opponentKey,
|
||||
verified = result.verified,
|
||||
online = 0
|
||||
)
|
||||
onUserSelect(user)
|
||||
}
|
||||
)
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 76.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSearchResultItem(
|
||||
result: MessageSearchResult,
|
||||
searchQuery: String,
|
||||
dateFormat: SimpleDateFormat,
|
||||
currentUserPublicKey: String,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
avatarRepository: AvatarRepository?,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isGroup = result.opponentKey.startsWith("#group:") || result.opponentKey.startsWith("group:")
|
||||
val displayName = when {
|
||||
result.opponentKey == currentUserPublicKey -> "Saved Messages"
|
||||
result.opponentTitle.isNotBlank() -> result.opponentTitle
|
||||
result.opponentUsername.isNotBlank() -> result.opponentUsername
|
||||
else -> result.opponentKey.take(8) + "..."
|
||||
}
|
||||
|
||||
// Build highlighted snippet: show text around the match
|
||||
val snippet = remember(result.plainText, searchQuery) {
|
||||
buildMessageSnippet(result.plainText, searchQuery)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey = result.opponentKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 48.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showOnlineIndicator = false,
|
||||
isOnline = false,
|
||||
displayName = displayName
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Top line: name + date
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (result.verified != 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(verified = result.verified, size = 16, isDarkTheme = isDarkTheme)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = dateFormat.format(Date(result.timestamp)),
|
||||
fontSize = 13.sp,
|
||||
color = secondaryTextColor,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// Bottom line: message snippet with highlighted match
|
||||
val annotated = remember(snippet, searchQuery) {
|
||||
buildHighlightedText(snippet, searchQuery, secondaryTextColor, PrimaryBlue)
|
||||
}
|
||||
Text(
|
||||
text = annotated,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a snippet around the first match, ~80 chars context */
|
||||
private fun buildMessageSnippet(text: String, query: String): String {
|
||||
val lower = text.lowercase()
|
||||
val queryLower = query.trim().lowercase()
|
||||
val idx = lower.indexOf(queryLower)
|
||||
if (idx < 0) return text.take(100)
|
||||
|
||||
val start = (idx - 30).coerceAtLeast(0)
|
||||
val end = (idx + queryLower.length + 50).coerceAtMost(text.length)
|
||||
val prefix = if (start > 0) "..." else ""
|
||||
val suffix = if (end < text.length) "..." else ""
|
||||
return prefix + text.substring(start, end).replace('\n', ' ') + suffix
|
||||
}
|
||||
|
||||
/** Build AnnotatedString with the query highlighted in blue */
|
||||
private fun buildHighlightedText(
|
||||
text: String,
|
||||
query: String,
|
||||
baseColor: Color,
|
||||
highlightColor: Color
|
||||
) = buildAnnotatedString {
|
||||
val lower = text.lowercase()
|
||||
val queryLower = query.trim().lowercase()
|
||||
var cursor = 0
|
||||
|
||||
while (cursor < text.length) {
|
||||
val matchIdx = lower.indexOf(queryLower, cursor)
|
||||
if (matchIdx < 0) {
|
||||
withStyle(SpanStyle(color = baseColor)) {
|
||||
append(text.substring(cursor))
|
||||
}
|
||||
break
|
||||
}
|
||||
if (matchIdx > cursor) {
|
||||
withStyle(SpanStyle(color = baseColor)) {
|
||||
append(text.substring(cursor, matchIdx))
|
||||
}
|
||||
}
|
||||
withStyle(SpanStyle(color = highlightColor, fontWeight = FontWeight.SemiBold)) {
|
||||
append(text.substring(matchIdx, matchIdx + queryLower.length))
|
||||
}
|
||||
cursor = matchIdx + queryLower.length
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🖼️ MEDIA TAB — grid of images from all chats
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -376,6 +376,10 @@ fun MessageBubble(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var suppressBubbleTapUntilMs by remember(message.id) { mutableLongStateOf(0L) }
|
||||
val suppressBubbleTapFromSpan: () -> Unit = {
|
||||
suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L
|
||||
}
|
||||
|
||||
val timeColor =
|
||||
remember(message.isOutgoing, isDarkTheme) {
|
||||
@@ -781,7 +785,12 @@ fun MessageBubble(
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
},
|
||||
onClick = onClick,
|
||||
onClick = {
|
||||
if (System.currentTimeMillis() <= suppressBubbleTapUntilMs) {
|
||||
return@combinedClickable
|
||||
}
|
||||
onClick()
|
||||
},
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.then(
|
||||
@@ -894,7 +903,8 @@ fun MessageBubble(
|
||||
linksEnabled = linksEnabled,
|
||||
onImageClick = onImageClick,
|
||||
onForwardedSenderClick = onForwardedSenderClick,
|
||||
onMentionClick = onMentionClick
|
||||
onMentionClick = onMentionClick,
|
||||
onTextSpanPressStart = suppressBubbleTapFromSpan
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
@@ -987,6 +997,8 @@ fun MessageBubble(
|
||||
true,
|
||||
onMentionClick =
|
||||
mentionClickHandler,
|
||||
onClickableSpanPressStart =
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick =
|
||||
textClickHandler,
|
||||
onLongClick =
|
||||
@@ -1077,6 +1089,8 @@ fun MessageBubble(
|
||||
enableLinks = linksEnabled,
|
||||
enableMentions = true,
|
||||
onMentionClick = mentionClickHandler,
|
||||
onClickableSpanPressStart =
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
@@ -1179,6 +1193,8 @@ fun MessageBubble(
|
||||
enableLinks = linksEnabled,
|
||||
enableMentions = true,
|
||||
onMentionClick = mentionClickHandler,
|
||||
onClickableSpanPressStart =
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
@@ -2314,7 +2330,8 @@ fun ForwardedMessagesBubble(
|
||||
linksEnabled: Boolean = true,
|
||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||
onMentionClick: (username: String) -> Unit = {}
|
||||
onMentionClick: (username: String) -> Unit = {},
|
||||
onTextSpanPressStart: (() -> Unit)? = null
|
||||
) {
|
||||
val backgroundColor =
|
||||
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
||||
@@ -2425,7 +2442,8 @@ fun ForwardedMessagesBubble(
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = linksEnabled,
|
||||
enableMentions = true,
|
||||
onMentionClick = if (linksEnabled) onMentionClick else null
|
||||
onMentionClick = if (linksEnabled) onMentionClick else null,
|
||||
onClickableSpanPressStart = onTextSpanPressStart
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.text.Editable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.LinkMovementMethod
|
||||
@@ -395,6 +396,7 @@ fun AppleEmojiText(
|
||||
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
|
||||
enableMentions: Boolean = false,
|
||||
onMentionClick: ((String) -> Unit)? = null,
|
||||
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||
minHeightMultiplier: Float = 1.5f
|
||||
@@ -439,8 +441,16 @@ fun AppleEmojiText(
|
||||
setMentionColor(mentionColor.toArgb())
|
||||
enableMentionHighlight(enableMentions)
|
||||
setOnMentionClickListener(onMentionClick)
|
||||
// 🔥 Поддержка обычного tap (например, для selection mode)
|
||||
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
||||
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||
val canUseTextViewClick = !enableLinks
|
||||
setOnClickListener(
|
||||
if (canUseTextViewClick && onClick != null) {
|
||||
View.OnClickListener { onClick.invoke() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
@@ -464,8 +474,16 @@ fun AppleEmojiText(
|
||||
view.setMentionColor(mentionColor.toArgb())
|
||||
view.enableMentionHighlight(enableMentions)
|
||||
view.setOnMentionClickListener(onMentionClick)
|
||||
// 🔥 Обновляем tap callback, чтобы не было stale lambda
|
||||
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
||||
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||
val canUseTextViewClick = !enableLinks
|
||||
view.setOnClickListener(
|
||||
if (canUseTextViewClick && onClick != null) {
|
||||
View.OnClickListener { onClick.invoke() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -508,14 +526,19 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
private var mentionColorValue: Int = 0xFF54A9EB.toInt()
|
||||
private var mentionsEnabled: Boolean = false
|
||||
private var mentionClickCallback: ((String) -> Unit)? = null
|
||||
private var clickableSpanPressStartCallback: (() -> Unit)? = null
|
||||
|
||||
// 🔥 Long press callback для selection в MessageBubble
|
||||
var onLongClickCallback: (() -> Unit)? = null
|
||||
private var downOnClickableSpan: Boolean = false
|
||||
private var suppressPerformClickOnce: Boolean = false
|
||||
|
||||
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
onLongClickCallback?.invoke()
|
||||
if (!downOnClickableSpan) {
|
||||
onLongClickCallback?.invoke()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -529,12 +552,64 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
* GestureDetector обрабатывает long press, затем передаем событие parent для ссылок
|
||||
*/
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downOnClickableSpan = isTouchOnClickableSpan(event)
|
||||
suppressPerformClickOnce = downOnClickableSpan
|
||||
if (downOnClickableSpan) {
|
||||
clickableSpanPressStartCallback?.invoke()
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_UP -> {
|
||||
downOnClickableSpan = false
|
||||
parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Позволяем GestureDetector обработать событие (для long press)
|
||||
gestureDetector.onTouchEvent(event)
|
||||
// Передаем событие дальше для обработки ссылок
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
if (suppressPerformClickOnce) {
|
||||
suppressPerformClickOnce = false
|
||||
return true
|
||||
}
|
||||
return super.performClick()
|
||||
}
|
||||
|
||||
private fun isTouchOnClickableSpan(event: MotionEvent): Boolean {
|
||||
val currentText = text as? Spanned ?: return false
|
||||
val hasClickableAtOffset: (Int) -> Boolean = { offset ->
|
||||
if (offset < 0 || offset > currentText.length) {
|
||||
false
|
||||
} else {
|
||||
val start = (offset - 1).coerceAtLeast(0)
|
||||
val end = (offset + 1).coerceAtMost(currentText.length)
|
||||
currentText.getSpans(start, end, ClickableSpan::class.java).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
val directOffset = runCatching { getOffsetForPosition(event.x, event.y) }.getOrNull()
|
||||
if (directOffset != null && hasClickableAtOffset(directOffset)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val textLayout = layout ?: return false
|
||||
val x = (event.x - totalPaddingLeft + scrollX).toInt()
|
||||
val y = (event.y - totalPaddingTop + scrollY).toInt()
|
||||
if (x < 0 || y < 0 || x > textLayout.width || y > textLayout.height) return false
|
||||
|
||||
val line = textLayout.getLineForVertical(y)
|
||||
val horizontal = x.toFloat().coerceIn(textLayout.getLineLeft(line), textLayout.getLineRight(line))
|
||||
val layoutOffset = textLayout.getOffsetForHorizontal(line, horizontal)
|
||||
return hasClickableAtOffset(layoutOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Установить цвет для ссылок
|
||||
*/
|
||||
@@ -556,6 +631,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
updateMovementMethod()
|
||||
}
|
||||
|
||||
fun setOnClickableSpanPressStartListener(listener: (() -> Unit)?) {
|
||||
clickableSpanPressStartCallback = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Включить/выключить кликабельные ссылки
|
||||
* @param enable - включить ссылки
|
||||
|
||||
@@ -7,6 +7,7 @@ data class ThemeWallpaper(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val preferredTheme: WallpaperTheme,
|
||||
val pairGroup: String,
|
||||
@DrawableRes val drawableRes: Int
|
||||
)
|
||||
|
||||
@@ -23,77 +24,119 @@ object ThemeWallpapers {
|
||||
id = "back_3",
|
||||
name = "Wallpaper 1",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_3
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_4",
|
||||
name = "Wallpaper 2",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_4
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_5",
|
||||
name = "Wallpaper 3",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_5
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_6",
|
||||
name = "Wallpaper 4",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_6
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_7",
|
||||
name = "Wallpaper 5",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_7
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_8",
|
||||
name = "Wallpaper 6",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_8
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_9",
|
||||
name = "Wallpaper 7",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_9
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_10",
|
||||
name = "Wallpaper 8",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_4",
|
||||
drawableRes = R.drawable.wallpaper_back_10
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_11",
|
||||
name = "Wallpaper 9",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_11
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_1",
|
||||
name = "Wallpaper 10",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_1
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_2",
|
||||
name = "Wallpaper 11",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_4",
|
||||
drawableRes = R.drawable.wallpaper_back_2
|
||||
)
|
||||
)
|
||||
|
||||
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
|
||||
private val byId: Map<String, ThemeWallpaper> = all.associateBy { it.id }
|
||||
private val byPairGroup: Map<String, List<ThemeWallpaper>> = all.groupBy { it.pairGroup }
|
||||
|
||||
fun findById(id: String): ThemeWallpaper? = byId[id]
|
||||
|
||||
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
|
||||
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
||||
return all.filter { it.preferredTheme == targetTheme }
|
||||
}
|
||||
|
||||
fun mapToTheme(wallpaperId: String, isDarkTheme: Boolean): String {
|
||||
if (wallpaperId.isBlank()) return ""
|
||||
|
||||
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
||||
val wallpaper = findById(wallpaperId) ?: return wallpaperId
|
||||
if (wallpaper.preferredTheme == targetTheme) return wallpaperId
|
||||
|
||||
val fromPair =
|
||||
byPairGroup[wallpaper.pairGroup]
|
||||
?.firstOrNull { it.preferredTheme == targetTheme }
|
||||
?.id
|
||||
if (!fromPair.isNullOrBlank()) return fromPair
|
||||
|
||||
return all.firstOrNull { it.preferredTheme == targetTheme }?.id ?: wallpaperId
|
||||
}
|
||||
|
||||
fun resolveWallpaperForTheme(
|
||||
currentWallpaperId: String,
|
||||
isDarkTheme: Boolean,
|
||||
darkThemeWallpaperId: String,
|
||||
lightThemeWallpaperId: String
|
||||
): String {
|
||||
val savedForTargetTheme = if (isDarkTheme) darkThemeWallpaperId else lightThemeWallpaperId
|
||||
val mappedSaved = mapToTheme(savedForTargetTheme, isDarkTheme)
|
||||
if (mappedSaved.isNotBlank()) return mappedSaved
|
||||
return mapToTheme(currentWallpaperId, isDarkTheme)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user