Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и 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-compose:2.5.0")
|
||||||
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
|
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
|
// uCrop for image cropping
|
||||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
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.ProfileScreen
|
||||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||||
import com.rosetta.messenger.ui.settings.ThemeScreen
|
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.settings.UpdatesScreen
|
||||||
import com.rosetta.messenger.ui.splash.SplashScreen
|
import com.rosetta.messenger.ui.splash.SplashScreen
|
||||||
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
||||||
@@ -743,6 +744,8 @@ fun MainScreen(
|
|||||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||||
.collectAsState(initial = "avatar")
|
.collectAsState(initial = "avatar")
|
||||||
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
|
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())
|
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||||
|
|
||||||
// AvatarRepository для работы с аватарами
|
// AvatarRepository для работы с аватарами
|
||||||
@@ -763,6 +766,29 @@ fun MainScreen(
|
|||||||
// Coroutine scope for profile updates
|
// Coroutine scope for profile updates
|
||||||
val mainScreenScope = rememberCoroutineScope()
|
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
|
// 🔥 Простая навигация с swipe back
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
|
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
|
||||||
@@ -971,7 +997,13 @@ fun MainScreen(
|
|||||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||||
onThemeModeChange = onThemeModeChange,
|
onThemeModeChange = onThemeModeChange,
|
||||||
onWallpaperChange = { wallpaperId ->
|
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 biometricAccountManager = remember { AccountManager(context) }
|
||||||
val activity = context as? FragmentActivity
|
val activity = context as? FragmentActivity
|
||||||
|
val isFingerprintSupported = remember {
|
||||||
|
biometricManager.isFingerprintHardwareAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFingerprintSupported) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
navStack = navStack.filterNot { it is Screen.Biometric }
|
||||||
|
}
|
||||||
|
return@SwipeBackContainer
|
||||||
|
}
|
||||||
|
|
||||||
BiometricEnableScreen(
|
BiometricEnableScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.biometric
|
package com.rosetta.messenger.biometric
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||||
@@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) {
|
|||||||
* Проверяет доступность STRONG биометрической аутентификации
|
* Проверяет доступность STRONG биометрической аутентификации
|
||||||
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
|
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
|
||||||
*/
|
*/
|
||||||
|
fun isFingerprintHardwareAvailable(): Boolean {
|
||||||
|
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||||
|
}
|
||||||
|
|
||||||
fun isBiometricAvailable(): BiometricAvailability {
|
fun isBiometricAvailable(): BiometricAvailability {
|
||||||
|
if (!isFingerprintHardwareAvailable()) {
|
||||||
|
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
|
||||||
|
}
|
||||||
|
|
||||||
val biometricManager = BiometricManager.from(context)
|
val biometricManager = BiometricManager.from(context)
|
||||||
|
|
||||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
||||||
|
|||||||
@@ -1592,7 +1592,7 @@ object MessageCrypto {
|
|||||||
// Reset bounds to default after first continuation
|
// Reset bounds to default after first continuation
|
||||||
lowerBoundary = 0x80
|
lowerBoundary = 0x80
|
||||||
upperBoundary = 0xBF
|
upperBoundary = 0xBF
|
||||||
|
// test
|
||||||
if (bytesSeen == bytesNeeded) {
|
if (bytesSeen == bytesNeeded) {
|
||||||
// Sequence complete — emit code point
|
// Sequence complete — emit code point
|
||||||
if (codePoint <= 0xFFFF) {
|
if (codePoint <= 0xFFFF) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class PreferencesManager(private val context: Context) {
|
|||||||
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
|
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
|
// Notifications
|
||||||
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||||
@@ -104,10 +106,21 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val chatWallpaperId: Flow<String> =
|
val chatWallpaperId: Flow<String> =
|
||||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
|
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) {
|
suspend fun setChatWallpaperId(value: String) {
|
||||||
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
|
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
|
// 🔔 NOTIFICATIONS
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -558,6 +558,18 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
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 для работы с диалогами */
|
/** DAO для работы с диалогами */
|
||||||
@@ -658,6 +670,18 @@ interface DialogDao {
|
|||||||
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
|
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
|
||||||
suspend fun getDialog(account: String, opponentKey: String): DialogEntity?
|
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(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ object ProtocolManager {
|
|||||||
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
||||||
// Pending resolves: publicKey → list of continuations waiting for the result
|
// Pending resolves: publicKey → list of continuations waiting for the result
|
||||||
private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
|
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.
|
// UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS.
|
||||||
private var uiLogsEnabled = true
|
private var uiLogsEnabled = true
|
||||||
@@ -473,6 +479,56 @@ object ProtocolManager {
|
|||||||
try { cont.resume(null) } catch (_: Exception) {}
|
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)
|
// 🚀 Обработчик транспортного сервера (0x0F)
|
||||||
@@ -973,6 +1029,18 @@ object ProtocolManager {
|
|||||||
return userInfoCache[publicKey]
|
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)
|
* 🔍 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.
|
* Accept a pending device login request.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.compose.animation.slideOutVertically
|
|||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
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.platform.LocalView
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
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.data.MessageRepository
|
||||||
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.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
|
||||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||||
@@ -411,6 +414,57 @@ fun ChatDetailScreen(
|
|||||||
val chatTitle =
|
val chatTitle =
|
||||||
if (isSavedMessages) "Saved Messages"
|
if (isSavedMessages) "Saved Messages"
|
||||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
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 = {
|
val openDialogInfo: () -> Unit = {
|
||||||
hideInputOverlays()
|
hideInputOverlays()
|
||||||
@@ -1725,7 +1779,7 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
if (!isSavedMessages &&
|
if (!isSavedMessages &&
|
||||||
!isGroupChat &&
|
!isGroupChat &&
|
||||||
(user.verified >
|
(chatHeaderVerified >
|
||||||
0 || isRosettaOfficial)
|
0 || isRosettaOfficial)
|
||||||
) {
|
) {
|
||||||
Spacer(
|
Spacer(
|
||||||
@@ -1736,7 +1790,7 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified =
|
verified =
|
||||||
if (user.verified > 0) user.verified else 1,
|
if (chatHeaderVerified > 0) chatHeaderVerified else 1,
|
||||||
size =
|
size =
|
||||||
16,
|
16,
|
||||||
isDarkTheme =
|
isDarkTheme =
|
||||||
@@ -2526,102 +2580,184 @@ fun ChatDetailScreen(
|
|||||||
verticalArrangement =
|
verticalArrangement =
|
||||||
Arrangement.Center
|
Arrangement.Center
|
||||||
) {
|
) {
|
||||||
if (isSavedMessages) {
|
val showSavedMessagesBackdrop =
|
||||||
val composition by
|
isSavedMessages &&
|
||||||
rememberLottieComposition(
|
hasChatWallpaper
|
||||||
LottieCompositionSpec
|
val savedMessagesBackdropShape =
|
||||||
.RawRes(
|
RoundedCornerShape(
|
||||||
R.raw.saved
|
22.dp
|
||||||
)
|
)
|
||||||
|
val savedMessagesBackdropColor =
|
||||||
|
if (isDarkTheme)
|
||||||
|
Color(
|
||||||
|
0xB3212121
|
||||||
)
|
)
|
||||||
val progress by
|
else
|
||||||
animateLottieCompositionAsState(
|
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 =
|
||||||
composition,
|
composition,
|
||||||
iterations =
|
progress = {
|
||||||
LottieConstants
|
progress
|
||||||
.IterateForever
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier.size(
|
||||||
|
120.dp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
LottieAnimation(
|
} else {
|
||||||
composition =
|
val composition by
|
||||||
composition,
|
rememberLottieComposition(
|
||||||
progress = {
|
LottieCompositionSpec
|
||||||
progress
|
.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 =
|
||||||
Modifier.size(
|
Modifier.height(
|
||||||
120.dp
|
16.dp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
Text(
|
||||||
val composition by
|
text =
|
||||||
rememberLottieComposition(
|
if (isSavedMessages
|
||||||
LottieCompositionSpec
|
|
||||||
.RawRes(
|
|
||||||
R.raw.speech
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val progress by
|
|
||||||
animateLottieCompositionAsState(
|
|
||||||
composition =
|
|
||||||
composition,
|
|
||||||
iterations =
|
|
||||||
LottieConstants
|
|
||||||
.IterateForever
|
|
||||||
)
|
|
||||||
LottieAnimation(
|
|
||||||
composition =
|
|
||||||
composition,
|
|
||||||
progress = {
|
|
||||||
progress
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier.size(
|
|
||||||
120.dp
|
|
||||||
)
|
)
|
||||||
|
"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 =
|
val normalizedUsername =
|
||||||
username.trim().trimStart('@').lowercase(Locale.ROOT)
|
username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||||
if (normalizedUsername.isBlank()) return@MessageBubble
|
if (normalizedUsername.isBlank()) return@MessageBubble
|
||||||
|
// Mention tap should not trigger bubble context-menu tap.
|
||||||
|
suppressTapAfterLongPress(selectionKey)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val normalizedCurrentUsername =
|
val normalizedCurrentUsername =
|
||||||
currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT)
|
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)) {
|
if (targetPublicKey.equals(currentUserPublicKey.trim(), ignoreCase = true)) {
|
||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
contextMenuMessage = null
|
contextMenuMessage = null
|
||||||
onUserProfileClick(
|
onNavigateToChat(
|
||||||
SearchUser(
|
SearchUser(
|
||||||
title = currentUserName.ifBlank { "You" },
|
title = currentUserName.ifBlank { "You" },
|
||||||
username = currentUserUsername.trim().trimStart('@'),
|
username = currentUserUsername.trim().trimStart('@'),
|
||||||
@@ -3027,7 +3174,7 @@ fun ChatDetailScreen(
|
|||||||
if (resolvedUser != null) {
|
if (resolvedUser != null) {
|
||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
contextMenuMessage = null
|
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.MessageLogger
|
||||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.*
|
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) {
|
fun retryMessage(message: ChatMessage) {
|
||||||
// Удаляем старое сообщение
|
// Удаляем старое сообщение
|
||||||
|
|||||||
@@ -1061,7 +1061,16 @@ fun ChatsListScreen(
|
|||||||
painter = TelegramIcons.Done,
|
painter = TelegramIcons.Done,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White,
|
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.network.SearchUser
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -70,6 +73,11 @@ import java.io.File
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
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
|
// Primary Blue color
|
||||||
private val PrimaryBlue = Color(0xFF54A9EB)
|
private val PrimaryBlue = Color(0xFF54A9EB)
|
||||||
@@ -77,6 +85,7 @@ private val PrimaryBlue = Color(0xFF54A9EB)
|
|||||||
/** Вкладки поиска как в Telegram */
|
/** Вкладки поиска как в Telegram */
|
||||||
private enum class SearchTab(val title: String) {
|
private enum class SearchTab(val title: String) {
|
||||||
CHATS("Chats"),
|
CHATS("Chats"),
|
||||||
|
MESSAGES("Messages"),
|
||||||
MEDIA("Media"),
|
MEDIA("Media"),
|
||||||
DOWNLOADS("Downloads"),
|
DOWNLOADS("Downloads"),
|
||||||
FILES("Files")
|
FILES("Files")
|
||||||
@@ -382,6 +391,18 @@ fun SearchScreen(
|
|||||||
onUserSelect = onUserSelect
|
onUserSelect = onUserSelect
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
SearchTab.MESSAGES -> {
|
||||||
|
MessagesTabContent(
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
searchLottieComposition = searchLottieComposition,
|
||||||
|
onUserSelect = onUserSelect
|
||||||
|
)
|
||||||
|
}
|
||||||
SearchTab.MEDIA -> {
|
SearchTab.MEDIA -> {
|
||||||
MediaTabContent(
|
MediaTabContent(
|
||||||
currentUserPublicKey = currentUserPublicKey,
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
@@ -639,14 +660,35 @@ private fun ChatsTabContent(
|
|||||||
} else {
|
} else {
|
||||||
// ═══ Search results ═══
|
// ═══ Search results ═══
|
||||||
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
|
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
|
||||||
|
val compactQuery = normalizedQuery.replace(Regex("\\s+"), " ").trim()
|
||||||
val normalizedPublicKey = currentUserPublicKey.lowercase()
|
val normalizedPublicKey = currentUserPublicKey.lowercase()
|
||||||
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
|
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
|
||||||
val normalizedName = ownAccountName.trim().lowercase()
|
val normalizedName = ownAccountName.trim().lowercase()
|
||||||
val hasValidOwnName =
|
val hasValidOwnName =
|
||||||
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName)
|
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 =
|
val isSavedMessagesSearch =
|
||||||
normalizedQuery.isNotEmpty() &&
|
normalizedQuery.isNotEmpty() &&
|
||||||
(normalizedPublicKey == normalizedQuery ||
|
(isSavedAliasSearch ||
|
||||||
|
normalizedPublicKey == normalizedQuery ||
|
||||||
normalizedPublicKey.startsWith(normalizedQuery) ||
|
normalizedPublicKey.startsWith(normalizedQuery) ||
|
||||||
normalizedPublicKey.take(8) == normalizedQuery ||
|
normalizedPublicKey.take(8) == normalizedQuery ||
|
||||||
normalizedPublicKey.takeLast(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
|
// 🖼️ MEDIA TAB — grid of images from all chats
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -376,6 +376,10 @@ fun MessageBubble(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
var suppressBubbleTapUntilMs by remember(message.id) { mutableLongStateOf(0L) }
|
||||||
|
val suppressBubbleTapFromSpan: () -> Unit = {
|
||||||
|
suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L
|
||||||
|
}
|
||||||
|
|
||||||
val timeColor =
|
val timeColor =
|
||||||
remember(message.isOutgoing, isDarkTheme) {
|
remember(message.isOutgoing, isDarkTheme) {
|
||||||
@@ -781,7 +785,12 @@ fun MessageBubble(
|
|||||||
remember {
|
remember {
|
||||||
MutableInteractionSource()
|
MutableInteractionSource()
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = {
|
||||||
|
if (System.currentTimeMillis() <= suppressBubbleTapUntilMs) {
|
||||||
|
return@combinedClickable
|
||||||
|
}
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
onLongClick = onLongClick
|
onLongClick = onLongClick
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
@@ -894,7 +903,8 @@ fun MessageBubble(
|
|||||||
linksEnabled = linksEnabled,
|
linksEnabled = linksEnabled,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
onForwardedSenderClick = onForwardedSenderClick,
|
onForwardedSenderClick = onForwardedSenderClick,
|
||||||
onMentionClick = onMentionClick
|
onMentionClick = onMentionClick,
|
||||||
|
onTextSpanPressStart = suppressBubbleTapFromSpan
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -987,6 +997,8 @@ fun MessageBubble(
|
|||||||
true,
|
true,
|
||||||
onMentionClick =
|
onMentionClick =
|
||||||
mentionClickHandler,
|
mentionClickHandler,
|
||||||
|
onClickableSpanPressStart =
|
||||||
|
suppressBubbleTapFromSpan,
|
||||||
onClick =
|
onClick =
|
||||||
textClickHandler,
|
textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
@@ -1077,6 +1089,8 @@ fun MessageBubble(
|
|||||||
enableLinks = linksEnabled,
|
enableLinks = linksEnabled,
|
||||||
enableMentions = true,
|
enableMentions = true,
|
||||||
onMentionClick = mentionClickHandler,
|
onMentionClick = mentionClickHandler,
|
||||||
|
onClickableSpanPressStart =
|
||||||
|
suppressBubbleTapFromSpan,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick // 🔥
|
||||||
@@ -1179,6 +1193,8 @@ fun MessageBubble(
|
|||||||
enableLinks = linksEnabled,
|
enableLinks = linksEnabled,
|
||||||
enableMentions = true,
|
enableMentions = true,
|
||||||
onMentionClick = mentionClickHandler,
|
onMentionClick = mentionClickHandler,
|
||||||
|
onClickableSpanPressStart =
|
||||||
|
suppressBubbleTapFromSpan,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick // 🔥
|
||||||
@@ -2314,7 +2330,8 @@ fun ForwardedMessagesBubble(
|
|||||||
linksEnabled: Boolean = true,
|
linksEnabled: Boolean = true,
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||||
onMentionClick: (username: String) -> Unit = {}
|
onMentionClick: (username: String) -> Unit = {},
|
||||||
|
onTextSpanPressStart: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val backgroundColor =
|
val backgroundColor =
|
||||||
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
||||||
@@ -2425,7 +2442,8 @@ fun ForwardedMessagesBubble(
|
|||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
enableLinks = linksEnabled,
|
enableLinks = linksEnabled,
|
||||||
enableMentions = true,
|
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.net.Uri
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.text.method.LinkMovementMethod
|
import android.text.method.LinkMovementMethod
|
||||||
@@ -395,6 +396,7 @@ fun AppleEmojiText(
|
|||||||
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
|
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
|
||||||
enableMentions: Boolean = false,
|
enableMentions: Boolean = false,
|
||||||
onMentionClick: ((String) -> Unit)? = null,
|
onMentionClick: ((String) -> Unit)? = null,
|
||||||
|
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||||
minHeightMultiplier: Float = 1.5f
|
minHeightMultiplier: Float = 1.5f
|
||||||
@@ -439,8 +441,16 @@ fun AppleEmojiText(
|
|||||||
setMentionColor(mentionColor.toArgb())
|
setMentionColor(mentionColor.toArgb())
|
||||||
enableMentionHighlight(enableMentions)
|
enableMentionHighlight(enableMentions)
|
||||||
setOnMentionClickListener(onMentionClick)
|
setOnMentionClickListener(onMentionClick)
|
||||||
// 🔥 Поддержка обычного tap (например, для selection mode)
|
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||||
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
// 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 ->
|
update = { view ->
|
||||||
@@ -464,8 +474,16 @@ fun AppleEmojiText(
|
|||||||
view.setMentionColor(mentionColor.toArgb())
|
view.setMentionColor(mentionColor.toArgb())
|
||||||
view.enableMentionHighlight(enableMentions)
|
view.enableMentionHighlight(enableMentions)
|
||||||
view.setOnMentionClickListener(onMentionClick)
|
view.setOnMentionClickListener(onMentionClick)
|
||||||
// 🔥 Обновляем tap callback, чтобы не было stale lambda
|
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||||
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
// 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
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -508,14 +526,19 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
private var mentionColorValue: Int = 0xFF54A9EB.toInt()
|
private var mentionColorValue: Int = 0xFF54A9EB.toInt()
|
||||||
private var mentionsEnabled: Boolean = false
|
private var mentionsEnabled: Boolean = false
|
||||||
private var mentionClickCallback: ((String) -> Unit)? = null
|
private var mentionClickCallback: ((String) -> Unit)? = null
|
||||||
|
private var clickableSpanPressStartCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
// 🔥 Long press callback для selection в MessageBubble
|
// 🔥 Long press callback для selection в MessageBubble
|
||||||
var onLongClickCallback: (() -> Unit)? = null
|
var onLongClickCallback: (() -> Unit)? = null
|
||||||
|
private var downOnClickableSpan: Boolean = false
|
||||||
|
private var suppressPerformClickOnce: Boolean = false
|
||||||
|
|
||||||
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
||||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
override fun onLongPress(e: MotionEvent) {
|
override fun onLongPress(e: MotionEvent) {
|
||||||
onLongClickCallback?.invoke()
|
if (!downOnClickableSpan) {
|
||||||
|
onLongClickCallback?.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -529,12 +552,64 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
* GestureDetector обрабатывает long press, затем передаем событие parent для ссылок
|
* GestureDetector обрабатывает long press, затем передаем событие parent для ссылок
|
||||||
*/
|
*/
|
||||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
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 обработать событие (для long press)
|
||||||
gestureDetector.onTouchEvent(event)
|
gestureDetector.onTouchEvent(event)
|
||||||
// Передаем событие дальше для обработки ссылок
|
// Передаем событие дальше для обработки ссылок
|
||||||
return super.dispatchTouchEvent(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()
|
updateMovementMethod()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setOnClickableSpanPressStartListener(listener: (() -> Unit)?) {
|
||||||
|
clickableSpanPressStartCallback = listener
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 Включить/выключить кликабельные ссылки
|
* 🔥 Включить/выключить кликабельные ссылки
|
||||||
* @param enable - включить ссылки
|
* @param enable - включить ссылки
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ data class ThemeWallpaper(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val preferredTheme: WallpaperTheme,
|
val preferredTheme: WallpaperTheme,
|
||||||
|
val pairGroup: String,
|
||||||
@DrawableRes val drawableRes: Int
|
@DrawableRes val drawableRes: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,77 +24,119 @@ object ThemeWallpapers {
|
|||||||
id = "back_3",
|
id = "back_3",
|
||||||
name = "Wallpaper 1",
|
name = "Wallpaper 1",
|
||||||
preferredTheme = WallpaperTheme.DARK,
|
preferredTheme = WallpaperTheme.DARK,
|
||||||
|
pairGroup = "pair_1",
|
||||||
drawableRes = R.drawable.wallpaper_back_3
|
drawableRes = R.drawable.wallpaper_back_3
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_4",
|
id = "back_4",
|
||||||
name = "Wallpaper 2",
|
name = "Wallpaper 2",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_1",
|
||||||
drawableRes = R.drawable.wallpaper_back_4
|
drawableRes = R.drawable.wallpaper_back_4
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_5",
|
id = "back_5",
|
||||||
name = "Wallpaper 3",
|
name = "Wallpaper 3",
|
||||||
preferredTheme = WallpaperTheme.DARK,
|
preferredTheme = WallpaperTheme.DARK,
|
||||||
|
pairGroup = "pair_2",
|
||||||
drawableRes = R.drawable.wallpaper_back_5
|
drawableRes = R.drawable.wallpaper_back_5
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_6",
|
id = "back_6",
|
||||||
name = "Wallpaper 4",
|
name = "Wallpaper 4",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_2",
|
||||||
drawableRes = R.drawable.wallpaper_back_6
|
drawableRes = R.drawable.wallpaper_back_6
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_7",
|
id = "back_7",
|
||||||
name = "Wallpaper 5",
|
name = "Wallpaper 5",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_2",
|
||||||
drawableRes = R.drawable.wallpaper_back_7
|
drawableRes = R.drawable.wallpaper_back_7
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_8",
|
id = "back_8",
|
||||||
name = "Wallpaper 6",
|
name = "Wallpaper 6",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_3",
|
||||||
drawableRes = R.drawable.wallpaper_back_8
|
drawableRes = R.drawable.wallpaper_back_8
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_9",
|
id = "back_9",
|
||||||
name = "Wallpaper 7",
|
name = "Wallpaper 7",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_1",
|
||||||
drawableRes = R.drawable.wallpaper_back_9
|
drawableRes = R.drawable.wallpaper_back_9
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_10",
|
id = "back_10",
|
||||||
name = "Wallpaper 8",
|
name = "Wallpaper 8",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_4",
|
||||||
drawableRes = R.drawable.wallpaper_back_10
|
drawableRes = R.drawable.wallpaper_back_10
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_11",
|
id = "back_11",
|
||||||
name = "Wallpaper 9",
|
name = "Wallpaper 9",
|
||||||
preferredTheme = WallpaperTheme.DARK,
|
preferredTheme = WallpaperTheme.DARK,
|
||||||
|
pairGroup = "pair_3",
|
||||||
drawableRes = R.drawable.wallpaper_back_11
|
drawableRes = R.drawable.wallpaper_back_11
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_1",
|
id = "back_1",
|
||||||
name = "Wallpaper 10",
|
name = "Wallpaper 10",
|
||||||
preferredTheme = WallpaperTheme.LIGHT,
|
preferredTheme = WallpaperTheme.LIGHT,
|
||||||
|
pairGroup = "pair_3",
|
||||||
drawableRes = R.drawable.wallpaper_back_1
|
drawableRes = R.drawable.wallpaper_back_1
|
||||||
),
|
),
|
||||||
ThemeWallpaper(
|
ThemeWallpaper(
|
||||||
id = "back_2",
|
id = "back_2",
|
||||||
name = "Wallpaper 11",
|
name = "Wallpaper 11",
|
||||||
preferredTheme = WallpaperTheme.DARK,
|
preferredTheme = WallpaperTheme.DARK,
|
||||||
|
pairGroup = "pair_4",
|
||||||
drawableRes = R.drawable.wallpaper_back_2
|
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> {
|
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
|
||||||
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
||||||
return all.filter { it.preferredTheme == targetTheme }
|
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
|
@DrawableRes
|
||||||
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user