fix(android): send gifs/stickers as image and add giphy search
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user