diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 456d8fd..ab6dcea 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -9,6 +11,15 @@ plugins { id("com.google.firebase.crashlytics") } +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } +} + +fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"") + android { namespace = "ru.daemonlord.messenger" compileSdk = 35 @@ -21,6 +32,12 @@ android { versionName = "0.1.0" buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"") buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"") + val giphyApiKey = ( + localProperties.getProperty("GIPHY_API_KEY") + ?: System.getenv("GIPHY_API_KEY") + ?: "" + ).trim() + buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"") buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true") buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true") buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index ba16778..ecda196 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -493,8 +493,8 @@ class NetworkMessageRepository @Inject constructor( val normalizedMime = mimeType.lowercase() val normalizedName = fileName.lowercase() return when { - normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "gif" - normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "sticker" + normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image" + normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image" normalizedMime.startsWith("image/") -> "image" normalizedMime.startsWith("video/") -> "video" normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice" diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 9717885..7a53c7c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -132,6 +132,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import ru.daemonlord.messenger.BuildConfig import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder @@ -141,6 +146,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.util.Locale +import java.net.URLEncoder import kotlinx.coroutines.delay import kotlin.math.roundToInt @@ -314,6 +320,10 @@ fun ChatScreen( var showInlineSearch by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) } var emojiPickerTab by remember { mutableStateOf(ComposerPickerTab.Emoji) } + var gifSearchQuery by remember { mutableStateOf("") } + var giphySearchItems by remember { mutableStateOf>(emptyList()) } + var isGiphyLoading by remember { mutableStateOf(false) } + var giphyErrorMessage by remember { mutableStateOf(null) } var isPickerSending by remember { mutableStateOf(false) } var showChatMenu by remember { mutableStateOf(false) } var showChatInfoSheet by remember { mutableStateOf(false) } @@ -325,6 +335,7 @@ fun ChatScreen( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } + val giphyApiKey = remember { BuildConfig.GIPHY_API_KEY.trim() } LaunchedEffect(state.isRecordingVoice) { if (!state.isRecordingVoice) return@LaunchedEffect @@ -357,6 +368,33 @@ fun ChatScreen( onInlineSearchChanged("") } } + LaunchedEffect(emojiPickerTab, gifSearchQuery, giphyApiKey) { + if (emojiPickerTab != ComposerPickerTab.Gif) return@LaunchedEffect + val query = gifSearchQuery.trim() + if (query.isBlank()) { + giphySearchItems = emptyList() + giphyErrorMessage = null + isGiphyLoading = false + return@LaunchedEffect + } + if (giphyApiKey.isBlank()) { + giphySearchItems = emptyList() + giphyErrorMessage = "Set GIPHY_API_KEY in local.properties" + isGiphyLoading = false + return@LaunchedEffect + } + isGiphyLoading = true + giphyErrorMessage = null + delay(300) + val found = fetchGiphySearchItems( + query = query, + apiKey = giphyApiKey, + limit = 24, + ) + giphySearchItems = found + giphyErrorMessage = if (found.isEmpty()) "No GIFs found" else null + isGiphyLoading = false + } LaunchedEffect(listState, timelineItems) { snapshotFlow { listState.layoutInfo.visibleItemsInfo @@ -1575,53 +1613,85 @@ fun ChatScreen( ComposerPickerTab.Sticker, -> { val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) { - defaultGifItems + if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems } else { defaultStickerItems } - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier - .fillMaxWidth() - .height(320.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(remoteItems) { remote -> - Surface( - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .clickable(enabled = !isPickerSending) { - scope.launch { - isPickerSending = true - val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) - if (payload != null) { - val stickerAdjustedName = if (emojiPickerTab == ComposerPickerTab.Sticker) { - "sticker_${payload.fileName}" - } else { - payload.fileName - } - onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) - showEmojiPicker = false - } - isPickerSending = false - } - }, - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AsyncImage( - model = remote.url, - contentDescription = remote.title, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - contentScale = ContentScale.Crop, + if (emojiPickerTab == ComposerPickerTab.Gif) { + OutlinedTextField( + value = gifSearchQuery, + onValueChange = { gifSearchQuery = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Search GIFs") }, + ) + when { + isGiphyLoading -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + + !giphyErrorMessage.isNullOrBlank() -> { + Text( + text = giphyErrorMessage.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(remoteItems) { remote -> + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = !isPickerSending) { + scope.launch { + isPickerSending = true + val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) + if (payload != null) { + val stickerAdjustedName = if (emojiPickerTab == ComposerPickerTab.Sticker) { + "sticker_${payload.fileName}" + } else { + payload.fileName + } + onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) + showEmojiPicker = false + } + isPickerSending = false + } + }, + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AsyncImage( + model = remote.url, + contentDescription = remote.title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentScale = ContentScale.Crop, + ) + } + } + } + } } } } @@ -2287,6 +2357,44 @@ private fun TextFieldValue.insertAtCursor(value: String): TextFieldValue { return copy(text = nextText, selection = TextRange(cursor)) } +private suspend fun fetchGiphySearchItems( + query: String, + apiKey: String, + limit: Int = 24, +): List { + if (query.isBlank() || apiKey.isBlank()) return emptyList() + return withContext(Dispatchers.IO) { + runCatching { + val encoded = URLEncoder.encode(query, "UTF-8") + val endpoint = "https://api.giphy.com/v1/gifs/search?api_key=$apiKey&q=$encoded&limit=$limit&rating=pg-13&lang=ru" + val raw = java.net.URL(endpoint).readText() + val data = Json.parseToJsonElement(raw) + .jsonObject["data"] + ?.jsonArray + .orEmpty() + + data.mapNotNull { node -> + val obj = node.jsonObject + val id = obj["id"]?.jsonPrimitive?.content.orEmpty() + val url = obj["images"] + ?.jsonObject + ?.get("fixed_width") + ?.jsonObject + ?.get("url") + ?.jsonPrimitive + ?.content + .orEmpty() + if (id.isBlank() || url.isBlank()) return@mapNotNull null + RemotePickerItem( + title = obj["title"]?.jsonPrimitive?.content.orEmpty().ifBlank { "GIF" }, + url = url, + fileNamePrefix = "gif_search_$id", + ) + } + }.getOrElse { emptyList() } + } +} + private suspend fun downloadRemoteMedia(url: String, fileNamePrefix: String): PickedMediaPayload? { return withContext(Dispatchers.IO) { runCatching {