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 { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
@@ -9,6 +11,15 @@ plugins {
id("com.google.firebase.crashlytics") 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 { android {
namespace = "ru.daemonlord.messenger" namespace = "ru.daemonlord.messenger"
compileSdk = 35 compileSdk = 35
@@ -21,6 +32,12 @@ android {
versionName = "0.1.0" versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"") buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"") 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_ACCOUNT_MANAGEMENT", "true")
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true") buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true") buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")

View File

@@ -493,8 +493,8 @@ class NetworkMessageRepository @Inject constructor(
val normalizedMime = mimeType.lowercase() val normalizedMime = mimeType.lowercase()
val normalizedName = fileName.lowercase() val normalizedName = fileName.lowercase()
return when { return when {
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "gif" normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "sticker" normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
normalizedMime.startsWith("image/") -> "image" normalizedMime.startsWith("image/") -> "image"
normalizedMime.startsWith("video/") -> "video" normalizedMime.startsWith("video/") -> "video"
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice" 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.core.audio.AppAudioFocusCoordinator
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
@@ -141,6 +146,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
import java.net.URLEncoder
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -314,6 +320,10 @@ fun ChatScreen(
var showInlineSearch by remember { mutableStateOf(false) } var showInlineSearch by remember { mutableStateOf(false) }
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
var emojiPickerTab by remember { mutableStateOf(ComposerPickerTab.Emoji) } 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 isPickerSending by remember { mutableStateOf(false) }
var showChatMenu by remember { mutableStateOf(false) } var showChatMenu by remember { mutableStateOf(false) }
var showChatInfoSheet by remember { mutableStateOf(false) } var showChatInfoSheet by remember { mutableStateOf(false) }
@@ -325,6 +335,7 @@ fun ChatScreen(
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) }
val giphyApiKey = remember { BuildConfig.GIPHY_API_KEY.trim() }
LaunchedEffect(state.isRecordingVoice) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -357,6 +368,33 @@ fun ChatScreen(
onInlineSearchChanged("") 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) { LaunchedEffect(listState, timelineItems) {
snapshotFlow { snapshotFlow {
listState.layoutInfo.visibleItemsInfo listState.layoutInfo.visibleItemsInfo
@@ -1575,53 +1613,85 @@ fun ChatScreen(
ComposerPickerTab.Sticker, ComposerPickerTab.Sticker,
-> { -> {
val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) { val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) {
defaultGifItems if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems
} else { } else {
defaultStickerItems defaultStickerItems
} }
LazyVerticalGrid( Column(
columns = GridCells.Fixed(3), modifier = Modifier.fillMaxWidth(),
modifier = Modifier verticalArrangement = Arrangement.spacedBy(8.dp),
.fillMaxWidth()
.height(320.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items(remoteItems) { remote -> if (emojiPickerTab == ComposerPickerTab.Gif) {
Surface( OutlinedTextField(
shape = RoundedCornerShape(10.dp), value = gifSearchQuery,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), onValueChange = { gifSearchQuery = it },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.clip(RoundedCornerShape(10.dp)) singleLine = true,
.clickable(enabled = !isPickerSending) { placeholder = { Text("Search GIFs") },
scope.launch { )
isPickerSending = true when {
val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) isGiphyLoading -> {
if (payload != null) { Row(
val stickerAdjustedName = if (emojiPickerTab == ComposerPickerTab.Sticker) { modifier = Modifier.fillMaxWidth(),
"sticker_${payload.fileName}" horizontalArrangement = Arrangement.Center,
} else { ) {
payload.fileName CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} }
onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) }
showEmojiPicker = false
} !giphyErrorMessage.isNullOrBlank() -> {
isPickerSending = false Text(
} text = giphyErrorMessage.orEmpty(),
}, style = MaterialTheme.typography.bodySmall,
) { color = MaterialTheme.colorScheme.onSurfaceVariant,
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AsyncImage(
model = remote.url,
contentDescription = remote.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop,
) )
} }
} }
} }
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)) 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? { private suspend fun downloadRemoteMedia(url: String, fileNamePrefix: String): PickedMediaPayload? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
runCatching { runCatching {