fix(android): send gifs/stickers as image and add giphy search
Some checks failed
Android CI / android (push) Failing after 5m47s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 20:41:04 +03:00
parent 23d636be7e
commit e3fdccdeaa
3 changed files with 166 additions and 41 deletions

View File

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

View File

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

View File

@@ -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<List<RemotePickerItem>>(emptyList()) }
var isGiphyLoading by remember { mutableStateOf(false) }
var giphyErrorMessage by remember { mutableStateOf<String?>(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,10 +1613,41 @@ fun ChatScreen(
ComposerPickerTab.Sticker,
-> {
val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) {
defaultGifItems
if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems
} else {
defaultStickerItems
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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
@@ -1625,6 +1694,7 @@ fun ChatScreen(
}
}
}
}
if (isPickerSending) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -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<RemotePickerItem> {
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 {