Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и 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:
2026-03-21 21:12:52 +05:00
parent c929685e04
commit 9d3e5bcb10
14 changed files with 1100 additions and 108 deletions

View File

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

View File

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

View File

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

View File

@@ -1592,7 +1592,7 @@ object MessageCrypto {
// Reset bounds to default after first continuation
lowerBoundary = 0x80
upperBoundary = 0xBF
// test
if (bytesSeen == bytesNeeded) {
// Sequence complete — emit code point
if (codePoint <= 0xFFFF) {

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

@@ -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) {
// Удаляем старое сообщение

View File

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

View File

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

View File

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

View File

@@ -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 - включить ссылки

View File

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