diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2d5b2323c..fc1af57ac 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,6 +9,9 @@ import java.io.File
import java.util.Properties
import org.gradle.api.GradleException
+fun String.escapeForBuildConfig(): String =
+ replace("\\", "\\\\").replace("\"", "\\\"")
+
// Config di firma letta da release/keystore.properties (non tracciato) o da env vars
val keystorePropertiesFileCandidates = listOf(
rootProject.file("release/keystore.properties"),
@@ -82,6 +85,7 @@ android {
val nightlyVersionCode = providers.gradleProperty("PASTIERA_NIGHTLY_VERSION_CODE").orNull?.toIntOrNull()
val nightlyVersionNameSuffix = providers.gradleProperty("PASTIERA_NIGHTLY_VERSION_SUFFIX").orNull ?: "-nightly"
val isFdroidBuild = gradleBooleanProperty("PASTIERA_FDROID_BUILD")
+ val klipyApiKey = providers.gradleProperty("PASTIERA_KLIPY_API_KEY").orNull ?: ""
defaultConfig {
applicationId = "it.palsoftware.pastiera"
@@ -91,6 +95,7 @@ android {
versionName = ciVersionName ?: defaultVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ buildConfigField("String", "KLIPY_API_KEY", "\"${klipyApiKey.escapeForBuildConfig()}\"")
}
signingConfigs {
@@ -262,6 +267,7 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.documentfile:documentfile:1.0.1")
implementation("androidx.compose.material:material-icons-extended")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("androidx.work:work-runtime-ktx:2.9.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bc8d838e0..ab2b462a9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -112,6 +112,12 @@
android:label="@string/online_layouts_title"
android:theme="@style/Theme.Pastiera" />
+
+
+
+
+
+
diff --git a/app/src/main/java/it/palsoftware/pastiera/LocalMediaPickerActivity.kt b/app/src/main/java/it/palsoftware/pastiera/LocalMediaPickerActivity.kt
new file mode 100644
index 000000000..f9d070d0e
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/LocalMediaPickerActivity.kt
@@ -0,0 +1,52 @@
+package it.palsoftware.pastiera
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+
+class LocalMediaPickerActivity : Activity() {
+ companion object {
+ const val ACTION_FOLDER_SELECTED = "it.palsoftware.pastiera.LOCAL_MEDIA_FOLDER_SELECTED"
+ const val EXTRA_URI = "uri"
+ private const val REQUEST_OPEN_TREE = 1001
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
+ addFlags(
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ )
+ }
+ startActivityForResult(intent, REQUEST_OPEN_TREE)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode != REQUEST_OPEN_TREE) {
+ finish()
+ return
+ }
+ if (resultCode == RESULT_OK) {
+ val uri = data?.data
+ if (uri != null) {
+ persistUri(uri, data)
+ SettingsManager.setLocalMediaFolderUri(this, uri.toString())
+ sendBroadcast(Intent(ACTION_FOLDER_SELECTED).apply {
+ setPackage(packageName)
+ putExtra(EXTRA_URI, uri.toString())
+ })
+ }
+ }
+ finish()
+ }
+
+ private fun persistUri(uri: Uri, data: Intent?) {
+ val flags = (data?.flags ?: 0) and
+ (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ contentResolver.takePersistableUriPermission(uri, flags or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
index ac6b297b3..a7cf25042 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
@@ -81,6 +81,8 @@ object SettingsManager {
private const val KEY_RESTORE_SYM_PAGE = "restore_sym_page" // SYM page to restore when returning from settings
private const val KEY_PENDING_RESTORE_SYM_PAGE = "pending_restore_sym_page" // Temporary SYM page state saved when opening settings
private const val KEY_SYM_PAGES_CONFIG = "sym_pages_config" // Order/enabled pages for SYM
+ private const val KEY_KLIPY_API_KEY = "klipy_api_key" // API key used for GIF search
+ private const val KEY_LOCAL_MEDIA_FOLDER_URI = "local_media_folder_uri" // Persisted SAF tree uri for local media tab
private const val KEY_SYM_AUTO_CLOSE = "sym_auto_close" // Auto-close SYM layout after key press
private const val KEY_SYM_AUTO_CLOSE_ON_TOUCH = "sym_auto_close_on_touch" // Auto-close SYM layout after tapping on-screen SYM keys
private const val KEY_SHIFT_TAP_LATCHES = "shift_tap_latches"
@@ -279,6 +281,7 @@ object SettingsManager {
private const val DEFAULT_LAYOUT_AWARE_CTRL_SHORTCUTS = false
private const val DEFAULT_AUTO_CORRECT_ENABLED = true
private const val DEFAULT_SUGGESTIONS_ENABLED = true
+ private const val DEFAULT_LOCAL_MEDIA_FOLDER_URI = ""
private const val DEFAULT_ACCENT_MATCHING_ENABLED = true
private const val DEFAULT_AUTO_REPLACE_ON_SPACE_ENTER = false
private const val DEFAULT_MAX_AUTO_REPLACE_DISTANCE = 1
@@ -4187,6 +4190,27 @@ object SettingsManager {
Log.e(TAG, "Error saving SYM pages config", e)
}
}
+
+ fun getKlipyApiKey(context: Context): String {
+ return getPreferences(context).getString(KEY_KLIPY_API_KEY, "") ?: ""
+ }
+
+ fun setKlipyApiKey(context: Context, apiKey: String) {
+ getPreferences(context).edit()
+ .putString(KEY_KLIPY_API_KEY, apiKey.trim())
+ .apply()
+ }
+
+ fun getLocalMediaFolderUri(context: Context): String {
+ return getPreferences(context).getString(KEY_LOCAL_MEDIA_FOLDER_URI, DEFAULT_LOCAL_MEDIA_FOLDER_URI)
+ ?: DEFAULT_LOCAL_MEDIA_FOLDER_URI
+ }
+
+ fun setLocalMediaFolderUri(context: Context, uri: String) {
+ getPreferences(context).edit()
+ .putString(KEY_LOCAL_MEDIA_FOLDER_URI, uri.trim())
+ .apply()
+ }
/**
* Gets whether SYM layout should auto-close after key press.
diff --git a/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt b/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt
index 4f0f74039..8be290c59 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt
@@ -86,6 +86,7 @@ fun SymCustomizationScreen(
SymPagesConfig.PAGE_SYMBOLS -> context.getString(R.string.sym_enable_symbols_page_title)
SymPagesConfig.PAGE_CLIPBOARD -> context.getString(R.string.sym_enable_clipboard_page_title)
SymPagesConfig.PAGE_EMOJI_PICKER -> context.getString(R.string.sym_enable_emoji_picker_page_title)
+ SymPagesConfig.PAGE_GIF_PICKER -> context.getString(R.string.sym_enable_gif_picker_page_title)
else -> pageId
}
var draggingPageId by remember { mutableStateOf(null) }
@@ -697,6 +698,48 @@ fun SymCustomizationScreen(
}
}
+ // GIF picker page toggle
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Keyboard,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.sym_enable_gif_picker_page_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1
+ )
+ Text(
+ text = stringResource(R.string.sym_enable_gif_picker_page_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2
+ )
+ }
+ Switch(
+ checked = symPagesConfig.gifPickerEnabled,
+ onCheckedChange = { enabled ->
+ persistSymPagesConfig(symPagesConfig.copy(gifPickerEnabled = enabled))
+ }
+ )
+ }
+ }
+
// Page order control
Surface(
modifier = Modifier
diff --git a/app/src/main/java/it/palsoftware/pastiera/SymPagesConfig.kt b/app/src/main/java/it/palsoftware/pastiera/SymPagesConfig.kt
index 1b8ef342d..5a9a8c794 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SymPagesConfig.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SymPagesConfig.kt
@@ -8,6 +8,7 @@ data class SymPagesConfig(
val symbolsEnabled: Boolean = true,
val clipboardEnabled: Boolean = false,
val emojiPickerEnabled: Boolean = false,
+ val gifPickerEnabled: Boolean = false,
val symPageOrder: List = DEFAULT_ORDER
) {
companion object {
@@ -15,7 +16,8 @@ data class SymPagesConfig(
const val PAGE_SYMBOLS = "symbols"
const val PAGE_CLIPBOARD = "clipboard"
const val PAGE_EMOJI_PICKER = "emoji_picker"
- val DEFAULT_ORDER = listOf(PAGE_EMOJI, PAGE_SYMBOLS, PAGE_CLIPBOARD, PAGE_EMOJI_PICKER)
+ const val PAGE_GIF_PICKER = "gif_picker"
+ val DEFAULT_ORDER = listOf(PAGE_EMOJI, PAGE_SYMBOLS, PAGE_CLIPBOARD, PAGE_EMOJI_PICKER, PAGE_GIF_PICKER)
}
fun normalizedOrder(): List {
@@ -38,6 +40,7 @@ data class SymPagesConfig(
PAGE_SYMBOLS -> symbolsEnabled
PAGE_CLIPBOARD -> clipboardEnabled
PAGE_EMOJI_PICKER -> emojiPickerEnabled
+ PAGE_GIF_PICKER -> gifPickerEnabled
else -> false
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/TextInputSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/TextInputSettingsScreen.kt
index 3b305d7a5..54aa33583 100644
--- a/app/src/main/java/it/palsoftware/pastiera/TextInputSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/TextInputSettingsScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.size
+import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -119,6 +120,10 @@ fun TextInputSettingsScreen(
var backspaceAtStartDelete by remember {
mutableStateOf(SettingsManager.getBackspaceAtStartDelete(context))
}
+
+ var klipyApiKey by remember {
+ mutableStateOf(SettingsManager.getKlipyApiKey(context))
+ }
// Handle system back button
BackHandler { onBack() }
@@ -383,6 +388,18 @@ fun TextInputSettingsScreen(
}
)
+ SettingsSectionHeader(text = stringResource(R.string.text_input_section_integrations))
+ SettingsTextFieldRow(
+ title = stringResource(R.string.klipy_api_key_title),
+ description = stringResource(R.string.klipy_api_key_description),
+ value = klipyApiKey,
+ placeholder = stringResource(R.string.klipy_api_key_placeholder),
+ onValueChange = { value ->
+ klipyApiKey = value
+ SettingsManager.setKlipyApiKey(context, value)
+ }
+ )
+
SettingsSectionHeader(text = stringResource(R.string.text_input_section_delete))
Surface(
modifier = Modifier
@@ -492,6 +509,46 @@ fun TextInputSettingsScreen(
}
}
+@Composable
+private fun SettingsTextFieldRow(
+ title: String,
+ description: String,
+ value: String,
+ placeholder: String,
+ onValueChange: (String) -> Unit
+) {
+ SettingsRowFrame(minHeight = 132.dp) {
+ Icon(
+ imageVector = Icons.Filled.TextFields,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ maxLines = 2
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ placeholder = { Text(placeholder) },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
@Composable
private fun SettingsSectionHeader(text: String) {
Column(modifier = Modifier.fillMaxWidth()) {
diff --git a/app/src/main/java/it/palsoftware/pastiera/backup/BackupContract.kt b/app/src/main/java/it/palsoftware/pastiera/backup/BackupContract.kt
index 7814edde4..e19af9c51 100644
--- a/app/src/main/java/it/palsoftware/pastiera/backup/BackupContract.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/backup/BackupContract.kt
@@ -236,6 +236,7 @@ object PreferenceSchemas {
"restore_sym_page" to PreferenceValueType.INT,
"pending_restore_sym_page" to PreferenceValueType.INT,
"sym_pages_config" to PreferenceValueType.STRING,
+ "local_media_folder_uri" to PreferenceValueType.STRING,
"sym_auto_close" to PreferenceValueType.BOOLEAN,
"emoji_picker_expanded_height" to PreferenceValueType.BOOLEAN,
"dismissed_releases" to PreferenceValueType.STRING,
diff --git a/app/src/main/java/it/palsoftware/pastiera/core/SymLayoutController.kt b/app/src/main/java/it/palsoftware/pastiera/core/SymLayoutController.kt
index 1184c9ca8..32e1d23f8 100644
--- a/app/src/main/java/it/palsoftware/pastiera/core/SymLayoutController.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/core/SymLayoutController.kt
@@ -24,7 +24,8 @@ class SymLayoutController(
EMOJI,
SYMBOLS,
CLIPBOARD,
- EMOJI_PICKER
+ EMOJI_PICKER,
+ GIF_PICKER
}
enum class SymKeyResult {
@@ -158,6 +159,7 @@ class SymLayoutController(
SymPage.SYMBOLS -> altSymManager.getSymMappings2()
SymPage.CLIPBOARD -> null // Clipboard doesn't use mappings
SymPage.EMOJI_PICKER -> null // Emoji picker doesn't use mappings
+ SymPage.GIF_PICKER -> null // GIF picker doesn't use mappings
else -> null
}
}
@@ -285,6 +287,7 @@ class SymLayoutController(
SymPage.SYMBOLS -> altSymManager.getSymMappings2()[keyCode]
SymPage.CLIPBOARD -> null // Clipboard doesn't use key mappings
SymPage.EMOJI_PICKER -> null // Emoji picker doesn't use key mappings
+ SymPage.GIF_PICKER -> null // GIF picker doesn't use key mappings
else -> null
}
@@ -324,6 +327,7 @@ class SymLayoutController(
SymPagesConfig.PAGE_SYMBOLS -> SymPage.SYMBOLS
SymPagesConfig.PAGE_CLIPBOARD -> SymPage.CLIPBOARD
SymPagesConfig.PAGE_EMOJI_PICKER -> SymPage.EMOJI_PICKER
+ SymPagesConfig.PAGE_GIF_PICKER -> SymPage.GIF_PICKER
else -> null
}
}
@@ -340,6 +344,7 @@ class SymLayoutController(
2 -> SymPage.SYMBOLS
3 -> SymPage.CLIPBOARD
4 -> SymPage.EMOJI_PICKER
+ 5 -> SymPage.GIF_PICKER
else -> null
}
}
@@ -349,12 +354,13 @@ class SymLayoutController(
SymPage.SYMBOLS -> 2
SymPage.CLIPBOARD -> 3
SymPage.EMOJI_PICKER -> 4
+ SymPage.GIF_PICKER -> 5
}
private fun alignSymPageToConfig(config: SymPagesConfig = SettingsManager.getSymPagesConfig(context)) {
val allowedValues = buildActivePages(config).map { it.toPrefValue() }
if (allowedValues.isEmpty()) {
- if (symPage != 0 && symPage != 2 && symPage != 3 && symPage != 4) {
+ if (symPage != 0 && symPage != 2 && symPage != 3 && symPage != 4 && symPage != 5) {
// Allow symbols page (2), clipboard page (3) and emoji picker page (4) even if all cycling pages are disabled
symPage = 0
persistSymPage()
@@ -367,7 +373,7 @@ class SymLayoutController(
}
// Allow symbols page (2), clipboard page (3) and emoji picker page (4) to remain active even if disabled in cycling settings
- if (symPage == 2 || symPage == 3 || symPage == 4) {
+ if (symPage == 2 || symPage == 3 || symPage == 4 || symPage == 5) {
return
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/gif/GifContentSender.kt b/app/src/main/java/it/palsoftware/pastiera/gif/GifContentSender.kt
new file mode 100644
index 000000000..fbed36dcd
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/gif/GifContentSender.kt
@@ -0,0 +1,176 @@
+package it.palsoftware.pastiera.gif
+
+import android.content.ClipData
+import android.content.ClipDescription
+import android.content.ClipboardManager
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.ImageDecoder
+import android.net.Uri
+import android.os.Build
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import androidx.core.content.FileProvider
+import androidx.core.view.inputmethod.EditorInfoCompat
+import androidx.core.view.inputmethod.InputConnectionCompat
+import androidx.core.view.inputmethod.InputContentInfoCompat
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+
+class GifContentSender(
+ private val context: Context,
+ private val okHttpClient: OkHttpClient = OkHttpClient()
+) {
+ companion object {
+ private const val GIF_MIME_TYPE = "image/gif"
+ private const val PNG_MIME_TYPE = "image/png"
+ }
+
+ fun fallbackLink(result: KlipyGifResult): String {
+ if (result.isLocal) return ""
+ return if (result.shareUrl.isNotBlank()) result.shareUrl else result.gifUrl
+ }
+
+ fun supportsMediaCommit(editorInfo: EditorInfo?, result: KlipyGifResult): Boolean {
+ val mimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo ?: return false)
+ if (result.mimeType.startsWith("image/", ignoreCase = true)) {
+ return mimeTypes.any { mime ->
+ mime.equals("image/*", ignoreCase = true) ||
+ mime.startsWith("image/", ignoreCase = true)
+ }
+ }
+ return mimeTypes.any { mime ->
+ mime.equals(result.mimeType, ignoreCase = true) ||
+ (result.mimeType == GIF_MIME_TYPE && mime.equals(GIF_MIME_TYPE, ignoreCase = true)) ||
+ mime.equals("image/*", ignoreCase = true)
+ }
+ }
+
+ fun prepareMedia(result: KlipyGifResult): PreparedGifContent {
+ val sharedDir = File(context.cacheDir, "shared_media").apply { mkdirs() }
+ val safeId = result.id.ifBlank { result.gifUrl.hashCode().toString() }.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val preparedMimeType = preparedMimeType(result)
+ val extension = extensionForMimeType(preparedMimeType)
+ val outputFile = File(sharedDir, "$safeId.$extension")
+ if (!outputFile.exists() || outputFile.length() == 0L) {
+ writeMediaToFile(result, outputFile, preparedMimeType)
+ }
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.gifprovider", outputFile)
+ return PreparedGifContent(
+ uri = uri,
+ description = ClipDescription(result.title.ifBlank { "Media" }, arrayOf(preparedMimeType)),
+ linkUri = fallbackLink(result).takeIf { it.isNotBlank() }?.let(Uri::parse)
+ )
+ }
+
+ fun commitPreparedGif(
+ preparedGifContent: PreparedGifContent,
+ inputConnection: InputConnection,
+ editorInfo: EditorInfo
+ ): Boolean {
+ val contentInfo = InputContentInfoCompat(
+ preparedGifContent.uri,
+ preparedGifContent.description,
+ preparedGifContent.linkUri
+ )
+ return InputConnectionCompat.commitContent(
+ inputConnection,
+ editorInfo,
+ contentInfo,
+ InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION,
+ null
+ )
+ }
+
+ fun insertFallbackLink(result: KlipyGifResult, inputConnection: InputConnection?) {
+ val fallback = fallbackLink(result)
+ if (fallback.isBlank()) return
+ if (inputConnection != null) {
+ inputConnection.commitText(fallback, 1)
+ } else {
+ copyFallbackLink(result)
+ }
+ }
+
+ fun copyFallbackLink(result: KlipyGifResult) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val fallback = fallbackLink(result)
+ if (fallback.isBlank()) return
+ clipboard.setPrimaryClip(ClipData.newPlainText("${result.mediaType.displayName.dropLast(1)} link", fallback))
+ }
+
+ fun hasTextFallback(result: KlipyGifResult): Boolean = fallbackLink(result).isNotBlank()
+
+ private fun extensionForMimeType(mimeType: String): String {
+ return when (mimeType.lowercase()) {
+ "image/gif" -> "gif"
+ "image/webp" -> "webp"
+ "image/png" -> "png"
+ else -> "jpg"
+ }
+ }
+
+ private fun preparedMimeType(result: KlipyGifResult): String {
+ return if (result.isLocal && !result.mimeType.equals(GIF_MIME_TYPE, ignoreCase = true)) {
+ PNG_MIME_TYPE
+ } else {
+ result.mimeType
+ }
+ }
+
+ private fun writeMediaToFile(result: KlipyGifResult, outputFile: File, preparedMimeType: String) {
+ if (result.isLocal && preparedMimeType == PNG_MIME_TYPE) {
+ transcodeLocalImageToPng(result.gifUrl, outputFile)
+ return
+ }
+ downloadToFile(result.gifUrl, outputFile)
+ }
+
+ private fun transcodeLocalImageToPng(url: String, outputFile: File) {
+ val uri = Uri.parse(url)
+ val bitmap = decodeLocalBitmap(uri) ?: throw IllegalStateException("Unable to decode local media uri")
+ outputFile.outputStream().use { output ->
+ if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) {
+ throw IllegalStateException("Unable to transcode local media uri")
+ }
+ }
+ }
+
+ private fun decodeLocalBitmap(uri: Uri): Bitmap? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri))
+ } else {
+ context.contentResolver.openInputStream(uri)?.use(BitmapFactory::decodeStream)
+ }
+ }
+
+ private fun downloadToFile(url: String, outputFile: File) {
+ if (url.startsWith("content://")) {
+ val uri = Uri.parse(url)
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ outputFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ } ?: throw IllegalStateException("Unable to open local media uri")
+ return
+ }
+ val request = Request.Builder().url(url).build()
+ okHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IllegalStateException("GIF download failed: ${response.code}")
+ }
+ val body = response.body ?: throw IllegalStateException("GIF download returned no body")
+ outputFile.outputStream().use { output ->
+ body.byteStream().copyTo(output)
+ }
+ }
+ }
+}
+
+data class PreparedGifContent(
+ val uri: Uri,
+ val description: ClipDescription,
+ val linkUri: Uri?
+)
diff --git a/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt
new file mode 100644
index 000000000..7d7ae6f20
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt
@@ -0,0 +1,240 @@
+package it.palsoftware.pastiera.gif
+
+import android.content.Context
+import it.palsoftware.pastiera.BuildConfig
+import it.palsoftware.pastiera.SettingsManager
+import okhttp3.HttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.Locale
+
+class KlipyGifClient(
+ private val context: Context,
+ private val okHttpClient: OkHttpClient = OkHttpClient()
+) {
+
+ fun hasConfiguredApiKey(): Boolean = resolveApiKey().isNotBlank()
+
+ fun search(
+ query: String,
+ mediaType: KlipyMediaType = KlipyMediaType.GIF,
+ limit: Int = 24,
+ page: Int = 1
+ ): List {
+ val apiKey = resolveApiKey()
+ if (apiKey.isBlank() || query.isBlank()) return emptyList()
+
+ val locale = Locale.getDefault().toLanguageTag().ifBlank { "en" }
+ val urls = listOf(
+ buildSearchUrl(apiKey, query, mediaType, limit, page, locale, useQParam = true),
+ buildSearchUrl(apiKey, query, mediaType, limit, page, locale, useQParam = false)
+ )
+
+ var lastFailure: Exception? = null
+ for (url in urls.distinct()) {
+ try {
+ return executeSearch(url, mediaType)
+ } catch (e: Exception) {
+ lastFailure = e
+ }
+ }
+ throw lastFailure ?: IllegalStateException("Klipy search failed")
+ }
+
+ fun trending(
+ mediaType: KlipyMediaType = KlipyMediaType.GIF,
+ limit: Int = 24,
+ page: Int = 1
+ ): List {
+ val apiKey = resolveApiKey()
+ if (apiKey.isBlank()) return emptyList()
+
+ val locale = Locale.getDefault().toLanguageTag().ifBlank { "en" }
+ val url = HttpUrl.Builder()
+ .scheme("https")
+ .host("api.klipy.com")
+ .addPathSegment("api")
+ .addPathSegment("v1")
+ .addPathSegment(apiKey)
+ .addPathSegment(mediaType.apiPathSegment)
+ .addPathSegment("trending")
+ .addQueryParameter("per_page", limit.coerceIn(1, 48).toString())
+ .addQueryParameter("page", page.coerceAtLeast(1).toString())
+ .addQueryParameter("rating", "pg-13")
+ .addQueryParameter("locale", locale)
+ .build()
+ return executeSearch(url, mediaType)
+ }
+
+ private fun resolveApiKey(): String {
+ val settingsKey = SettingsManager.getKlipyApiKey(context)
+ return if (settingsKey.isNotBlank()) settingsKey else BuildConfig.KLIPY_API_KEY
+ }
+
+ private fun buildSearchUrl(
+ apiKey: String,
+ query: String,
+ mediaType: KlipyMediaType,
+ limit: Int,
+ page: Int,
+ locale: String,
+ useQParam: Boolean
+ ): HttpUrl {
+ val builder = HttpUrl.Builder()
+ .scheme("https")
+ .host("api.klipy.com")
+ .addPathSegment("api")
+ .addPathSegment("v1")
+ .addPathSegment(apiKey)
+ .addPathSegment(mediaType.apiPathSegment)
+ .addPathSegment("search")
+ .addQueryParameter(if (useQParam) "q" else "query", query)
+ .addQueryParameter("per_page", limit.coerceIn(1, 48).toString())
+ .addQueryParameter("page", page.coerceAtLeast(1).toString())
+ .addQueryParameter("rating", "pg-13")
+ .addQueryParameter("locale", locale)
+ return builder.build()
+ }
+
+ private fun executeSearch(url: HttpUrl, mediaType: KlipyMediaType): List {
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", "application/json")
+ .build()
+
+ okHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IllegalStateException("Klipy search failed: ${response.code}")
+ }
+ val body = response.body?.string().orEmpty()
+ return parseResults(body, mediaType)
+ }
+ }
+
+ private fun parseResults(body: String, mediaType: KlipyMediaType): List {
+ if (body.isBlank()) return emptyList()
+
+ val root = JSONObject(body)
+ val nestedData = root.optJSONObject("data")
+ val items = nestedData?.optJSONArray("data")
+ ?: root.optJSONArray("data")
+ ?: root.optJSONArray("results")
+ ?: root.optJSONArray("gifs")
+ ?: JSONArray()
+
+ val results = mutableListOf()
+ for (index in 0 until items.length()) {
+ val item = items.optJSONObject(index) ?: continue
+ parseResult(item, mediaType)?.let(results::add)
+ }
+ return results
+ }
+
+ private fun parseResult(item: JSONObject, mediaType: KlipyMediaType): KlipyGifResult? {
+ val mediaFormats = item.optJSONObject("media_formats")
+ val images = item.optJSONObject("images")
+ val file = item.optJSONObject("file")
+
+ val mediaUrl = firstNonBlank(
+ extractFormatUrl(mediaFormats, listOf("gif", "mediumgif", "tinygif", "nanogif")),
+ extractFormatUrl(mediaFormats, listOf("webp", "tinywebp", "nanowebp")),
+ extractFormatUrl(mediaFormats, listOf("jpg", "png")),
+ extractImageUrl(images, listOf("original", "fixed_width", "downsized")),
+ extractFileUrl(file, listOf("sm", "md", "hd", "xs"), "gif"),
+ extractFileUrl(file, listOf("sm", "md", "hd", "xs"), "webp"),
+ extractFileUrl(file, listOf("sm", "md", "hd", "xs"), "png"),
+ extractFileUrl(file, listOf("sm", "md", "hd", "xs"), "jpg"),
+ extractFileUrl(file, listOf("sm", "md", "hd", "xs"), "jpeg")
+ ) ?: return null
+
+ val previewUrl = firstNonBlank(
+ extractFormatUrl(mediaFormats, listOf("tinygifpreview", "nanogifpreview", "gifpreview", "tinygif", "nanogif")),
+ extractFormatUrl(mediaFormats, listOf("tinywebp", "nanowebp", "webp", "jpg", "png")),
+ extractImageUrl(images, listOf("preview_gif", "fixed_width_still", "downsized_still", "original_still")),
+ extractFileUrl(file, listOf("sm", "xs", "md", "hd"), "gif"),
+ extractFileUrl(file, listOf("sm", "xs", "md", "hd"), "webp"),
+ extractFileUrl(file, listOf("sm", "xs", "md", "hd"), "png"),
+ extractFileUrl(file, listOf("sm", "xs", "md", "hd"), "jpg"),
+ extractFileUrl(file, listOf("sm", "xs", "md", "hd"), "jpeg"),
+ mediaUrl
+ ) ?: return null
+
+ val shareUrl = firstNonBlank(
+ item.optString("itemurl"),
+ item.optString("share_url"),
+ item.optString("url"),
+ mediaUrl
+ ) ?: mediaUrl
+
+ val title = firstNonBlank(
+ item.optString("title"),
+ item.optString("content_description"),
+ item.optString("slug"),
+ mediaType.displayName.dropLast(1)
+ ) ?: mediaType.displayName.dropLast(1)
+
+ val mimeType = inferMimeType(mediaUrl, mediaType)
+
+ return KlipyGifResult(
+ id = item.optString("id", shareUrl),
+ title = title,
+ mediaType = mediaType,
+ previewUrl = previewUrl,
+ gifUrl = mediaUrl,
+ mimeType = mimeType,
+ shareUrl = shareUrl
+ )
+ }
+
+ private fun extractFormatUrl(mediaFormats: JSONObject?, names: List): String? {
+ mediaFormats ?: return null
+ names.forEach { formatName ->
+ val candidate = mediaFormats.optJSONObject(formatName)?.optString("url")
+ if (!candidate.isNullOrBlank()) {
+ return candidate
+ }
+ }
+ return null
+ }
+
+ private fun extractImageUrl(images: JSONObject?, names: List): String? {
+ images ?: return null
+ names.forEach { imageName ->
+ val candidate = images.optJSONObject(imageName)?.optString("url")
+ if (!candidate.isNullOrBlank()) {
+ return candidate
+ }
+ }
+ return null
+ }
+
+ private fun extractFileUrl(file: JSONObject?, sizeNames: List, formatName: String): String? {
+ file ?: return null
+ sizeNames.forEach { sizeName ->
+ val sizeObject = file.optJSONObject(sizeName) ?: return@forEach
+ val candidate = sizeObject.optJSONObject(formatName)?.optString("url")
+ if (!candidate.isNullOrBlank()) {
+ return candidate
+ }
+ }
+ return null
+ }
+
+ private fun firstNonBlank(vararg values: String?): String? {
+ return values.firstOrNull { !it.isNullOrBlank() }?.trim()
+ }
+
+ private fun inferMimeType(url: String, mediaType: KlipyMediaType): String {
+ val normalized = url.lowercase(Locale.ROOT)
+ return when {
+ normalized.endsWith(".gif") -> "image/gif"
+ normalized.endsWith(".webp") -> "image/webp"
+ normalized.endsWith(".png") -> "image/png"
+ normalized.endsWith(".jpg") || normalized.endsWith(".jpeg") -> "image/jpeg"
+ mediaType == KlipyMediaType.GIF || mediaType == KlipyMediaType.STICKER -> "image/gif"
+ else -> "image/jpeg"
+ }
+ }
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt
new file mode 100644
index 000000000..706199a30
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt
@@ -0,0 +1,22 @@
+package it.palsoftware.pastiera.gif
+
+enum class KlipyMediaType(
+ val apiPathSegment: String,
+ val displayName: String,
+ val singularName: String
+) {
+ GIF("gifs", "GIFs", "GIF"),
+ STICKER("stickers", "Stickers", "sticker"),
+ LOCAL("local", "Local", "image")
+}
+
+data class KlipyGifResult(
+ val id: String,
+ val title: String,
+ val mediaType: KlipyMediaType,
+ val previewUrl: String,
+ val gifUrl: String,
+ val mimeType: String,
+ val shareUrl: String,
+ val isLocal: Boolean = false
+)
diff --git a/app/src/main/java/it/palsoftware/pastiera/gif/LocalMediaRepository.kt b/app/src/main/java/it/palsoftware/pastiera/gif/LocalMediaRepository.kt
new file mode 100644
index 000000000..b404fa0b6
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/gif/LocalMediaRepository.kt
@@ -0,0 +1,41 @@
+package it.palsoftware.pastiera.gif
+
+import android.content.Context
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import it.palsoftware.pastiera.SettingsManager
+
+class LocalMediaRepository(private val context: Context) {
+
+ fun getSelectedFolderUri(): Uri? {
+ val value = SettingsManager.getLocalMediaFolderUri(context)
+ if (value.isBlank()) return null
+ return runCatching { Uri.parse(value) }.getOrNull()
+ }
+
+ fun getItems(limit: Int = 48): List {
+ val treeUri = getSelectedFolderUri() ?: return emptyList()
+ val root = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList()
+ return root.listFiles()
+ .asSequence()
+ .filter { it.isFile }
+ .filter { file -> file.type?.startsWith("image/") == true }
+ .sortedByDescending { it.lastModified() }
+ .take(limit)
+ .mapNotNull { file ->
+ val uri = file.uri.toString()
+ val mimeType = file.type ?: "image/jpeg"
+ KlipyGifResult(
+ id = uri,
+ title = file.name ?: "Local media",
+ mediaType = KlipyMediaType.LOCAL,
+ previewUrl = uri,
+ gifUrl = uri,
+ mimeType = mimeType,
+ shareUrl = uri,
+ isLocal = true
+ )
+ }
+ .toList()
+ }
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt
index c4b1de95e..f524a7420 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt
@@ -6,6 +6,7 @@ import android.view.KeyEvent
import android.widget.LinearLayout
import android.view.inputmethod.InputConnection
import it.palsoftware.pastiera.SettingsManager
+import it.palsoftware.pastiera.gif.KlipyGifResult
/**
* Coordinates the two StatusBarController instances (full input view vs
@@ -111,6 +112,13 @@ class CandidatesBarController(
inputStatusBar.onEmojiPageRequested = value
candidatesStatusBar.onEmojiPageRequested = value
}
+
+ var onGifSelected: ((KlipyGifResult) -> Unit)? = null
+ set(value) {
+ field = value
+ inputStatusBar.onGifSelected = value
+ candidatesStatusBar.onGifSelected = value
+ }
var onSymbolsPageRequested: (() -> Unit)? = null
set(value) {
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt
index 22e279a83..4c0400edd 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import android.os.Handler
import android.os.Looper
import android.view.View
@@ -48,6 +49,8 @@ import it.palsoftware.pastiera.data.layout.LayoutFileStore
import it.palsoftware.pastiera.data.layout.LayoutMapping
import it.palsoftware.pastiera.data.mappings.KeyMappingLoader
import it.palsoftware.pastiera.data.variation.VariationRepository
+import it.palsoftware.pastiera.gif.GifContentSender
+import it.palsoftware.pastiera.gif.KlipyGifResult
import it.palsoftware.pastiera.inputmethod.SpeechRecognitionActivity
import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils
import it.palsoftware.pastiera.inputmethod.aospkeyboard.AospKeyboardView
@@ -229,6 +232,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService() {
private lateinit var textInputController: TextInputController
private lateinit var autoCorrectionManager: AutoCorrectionManager
private lateinit var suggestionController: SuggestionController
+ private lateinit var gifContentSender: GifContentSender
private lateinit var variationStateController: VariationStateController
private lateinit var inputEventRouter: InputEventRouter
private lateinit var typingSoundPlayer: TypingSoundPlayer
@@ -923,6 +927,55 @@ class PhysicalKeyboardInputMethodService : InputMethodService() {
return actionId?.let { performEnterEditorAction(keyCode, it, ic, event) } ?: false
}
+ private fun sendGifResult(result: KlipyGifResult) {
+ val inputConnection = currentInputConnection
+ val editorInfo = currentInputEditorInfo
+ if (inputConnection == null) {
+ if (gifContentSender.hasTextFallback(result)) {
+ gifContentSender.copyFallbackLink(result)
+ Toast.makeText(this, getString(R.string.gif_picker_link_copied, result.mediaType.singularName), Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(this, getString(R.string.gif_picker_not_supported, result.mediaType.singularName), Toast.LENGTH_SHORT).show()
+ }
+ return
+ }
+
+ CoroutineScope(Dispatchers.Main).launch {
+ val committed = if (gifContentSender.supportsMediaCommit(editorInfo, result)) {
+ runCatching {
+ val preparedMedia = withContext(Dispatchers.IO) {
+ gifContentSender.prepareMedia(result)
+ }
+ val nonNullEditorInfo = editorInfo ?: return@runCatching false
+ gifContentSender.commitPreparedGif(preparedMedia, inputConnection, nonNullEditorInfo)
+ }.getOrDefault(false)
+ } else {
+ false
+ }
+
+ if (committed) {
+ Toast.makeText(
+ this@PhysicalKeyboardInputMethodService,
+ getString(R.string.gif_picker_sent, result.mediaType.singularName),
+ Toast.LENGTH_SHORT
+ ).show()
+ } else if (gifContentSender.hasTextFallback(result)) {
+ gifContentSender.insertFallbackLink(result, inputConnection)
+ Toast.makeText(
+ this@PhysicalKeyboardInputMethodService,
+ getString(R.string.gif_picker_fallback_link_inserted, result.mediaType.singularName),
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ this@PhysicalKeyboardInputMethodService,
+ getString(R.string.gif_picker_not_supported, result.mediaType.singularName),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+
private fun notifyDebugKeyEvent(
keyCode: Int,
event: KeyEvent?,
@@ -1420,6 +1473,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService() {
// Initialize clipboard history manager first (needed by candidatesBarController)
clipboardHistoryManager = ClipboardHistoryManager(this)
clipboardHistoryManager.onCreate()
+ gifContentSender = GifContentSender(this)
candidatesBarController = CandidatesBarController(this, clipboardHistoryManager, assets, PhysicalKeyboardInputMethodService::class.java)
candidatesBarController.onAddUserWord = { word ->
@@ -1548,6 +1602,9 @@ class PhysicalKeyboardInputMethodService : InputMethodService() {
symLayoutController.openEmojiPickerPage()
updateStatusBarText()
}
+ candidatesBarController.onGifSelected = { result ->
+ sendGifResult(result)
+ }
candidatesBarController.onEmojiPageRequested = {
ensureInputViewCreated()
symLayoutController.openEmojiPage()
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt
index 6d5f7d2f5..cfe54a897 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt
@@ -28,6 +28,9 @@ import it.palsoftware.pastiera.SymPagesConfig
import it.palsoftware.pastiera.data.layout.LayoutFileStore
import it.palsoftware.pastiera.data.mappings.KeyMappingLoader
import it.palsoftware.pastiera.data.variation.VariationRepository
+import it.palsoftware.pastiera.gif.KlipyGifClient
+import it.palsoftware.pastiera.gif.KlipyGifResult
+import it.palsoftware.pastiera.inputmethod.ui.GifPickerView
import kotlin.math.max
import android.view.KeyEvent
import android.view.inputmethod.InputMethodManager
@@ -129,6 +132,8 @@ class StatusBarController(
}
var onEmojiPageRequested: (() -> Unit)? = null
+
+ var onGifSelected: ((KlipyGifResult) -> Unit)? = null
var onSymbolsPageRequested: (() -> Unit)? = null
set(value) {
@@ -293,6 +298,7 @@ class StatusBarController(
private var clipboardHistoryView: ClipboardHistoryView? = null
private var lastClipboardCountRendered: Int = -1
private var emojiPickerView: EmojiPickerView? = null
+ private var gifPickerView: GifPickerView? = null
private var emojiKeyButtons: MutableList = mutableListOf()
private var lastSymPageRendered: Int = 0
private var lastSymMappingsRendered: Map? = null
@@ -1073,6 +1079,29 @@ class StatusBarController(
lastSymPageRendered = 4
}
+ private fun updateGifPickerView() {
+ val container = emojiKeyboardContainer ?: return
+ container.setPadding(0, 0, 0, emojiKeyboardBottomPaddingPx)
+
+ val view = gifPickerView ?: GifPickerView(
+ context = context,
+ gifClient = KlipyGifClient(context),
+ onGifSelected = { result -> onGifSelected?.invoke(result) }
+ ).also { gifPickerView = it }
+ val wasJustAdded = view.parent !== container
+ if (wasJustAdded) {
+ container.removeAllViews()
+ emojiKeyButtons.clear()
+ container.addView(view)
+ }
+ if (lastSymPageRendered != 5) {
+ view.refresh()
+ } else if (wasJustAdded) {
+ view.scrollToTop()
+ }
+ lastSymPageRendered = 5
+ }
+
/**
* Aggiorna la griglia emoji/caratteri con le mappature SYM.
* @param symMappings Le mappature da visualizzare
@@ -2801,6 +2830,8 @@ class StatusBarController(
} else if (snapshot.symPage == 4) {
// Show emoji picker view
updateEmojiPickerView(inputConnection, softwareKeyboardHeight = lastSoftwareKeyboardHeight.takeIf { isFullSoftwareKeyboardMode && it > 0 })
+ } else if (snapshot.symPage == 5) {
+ updateGifPickerView()
} else if (isSoftwareKeyboardSymbolPage && symMappings != null) {
updateSoftwareSymbolKeyboard(symMappings, snapshot, inputConnection)
} else if (symMappings != null) {
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt
new file mode 100644
index 000000000..07b38aa0d
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt
@@ -0,0 +1,760 @@
+package it.palsoftware.pastiera.inputmethod.ui
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Color
+import android.graphics.ImageDecoder
+import android.net.Uri
+import android.text.Editable
+import android.text.InputType
+import android.text.TextWatcher
+import android.util.LruCache
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.view.ViewGroup
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.Drawable
+import android.widget.EditText
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import it.palsoftware.pastiera.R
+import it.palsoftware.pastiera.LocalMediaPickerActivity
+import it.palsoftware.pastiera.gif.KlipyGifClient
+import it.palsoftware.pastiera.gif.KlipyGifResult
+import it.palsoftware.pastiera.gif.KlipyMediaType
+import it.palsoftware.pastiera.gif.LocalMediaRepository
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import android.widget.Toast
+import java.nio.ByteBuffer
+
+class GifPickerView(
+ context: Context,
+ private val gifClient: KlipyGifClient,
+ private val onGifSelected: (KlipyGifResult) -> Unit
+) : FrameLayout(context) {
+
+ private val searchField: EditText
+ private val recyclerView: RecyclerView
+ private val loadingView: ProgressBar
+ private val emptyView: TextView
+ private val attributionView: TextView
+ private val mediaTypeTabs: LinearLayout
+ private val localFolderBar: LinearLayout
+ private val localFolderText: TextView
+ private val previewOverlay: FrameLayout
+ private val previewImageView: ImageView
+ private val previewTitleView: TextView
+ private val sendButton: TextView
+ private val cancelButton: TextView
+ private val resultAdapter = GifResultAdapter(
+ onGifTapped = { showPreview(it) },
+ onGifLongPressed = { copyGifLink(it) }
+ )
+ private val fixedHeight = dpToPx(312f)
+ private val smallPadding = dpToPx(8f)
+ private val previewSpacing = dpToPx(8f)
+ private val columns = 3
+ private var searchJob: Job? = null
+ private var searchQuery = ""
+ private var searchInputCaptureEnabled = true
+ private var coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var selectedGif: KlipyGifResult? = null
+ private var selectedMediaType: KlipyMediaType = KlipyMediaType.GIF
+ private var localFolderReceiverRegistered = false
+ private val localMediaRepository = LocalMediaRepository(context)
+ private val localFolderReceiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == LocalMediaPickerActivity.ACTION_FOLDER_SELECTED) {
+ updateLocalFolderBar()
+ if (selectedMediaType == KlipyMediaType.LOCAL) {
+ refresh()
+ }
+ }
+ }
+ }
+
+ init {
+ setBackgroundColor(Color.TRANSPARENT)
+
+ val vertical = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, fixedHeight)
+ }
+
+ searchField = EditText(context).apply {
+ hint = context.getString(R.string.gif_picker_search_placeholder)
+ setTextColor(Color.WHITE)
+ setHintTextColor(Color.argb(160, 255, 255, 255))
+ textSize = 14f
+ setSingleLine(true)
+ inputType = InputType.TYPE_CLASS_TEXT
+ setBackgroundColor(Color.argb(30, 255, 255, 255))
+ val padH = dpToPx(10f)
+ val padV = dpToPx(6f)
+ setPadding(padH, padV, padH, padV)
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ setMargins(smallPadding, smallPadding, smallPadding, 0)
+ }
+ addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) {
+ val newQuery = s?.toString().orEmpty()
+ if (newQuery == searchQuery) return
+ searchQuery = newQuery
+ scheduleSearch()
+ }
+ })
+ }
+
+ mediaTypeTabs = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ setMargins(smallPadding, smallPadding / 2, smallPadding, 0)
+ }
+ }
+ KlipyMediaType.entries.forEach { type ->
+ mediaTypeTabs.addView(buildMediaTypeTab(type))
+ }
+
+ localFolderBar = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ setMargins(smallPadding, smallPadding / 2, smallPadding, 0)
+ }
+ visibility = View.GONE
+ }
+ localFolderText = TextView(context).apply {
+ setTextColor(Color.argb(210, 255, 255, 255))
+ textSize = 12f
+ layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
+ }
+ val chooseFolderButton = buildPreviewButton(context.getString(R.string.local_media_choose_folder)).apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ setOnClickListener { openLocalFolderPicker() }
+ }
+ localFolderBar.addView(localFolderText)
+ localFolderBar.addView(chooseFolderButton)
+
+ recyclerView = RecyclerView(context).apply {
+ overScrollMode = View.OVER_SCROLL_ALWAYS
+ clipToPadding = false
+ setPadding(smallPadding, smallPadding, smallPadding, smallPadding)
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f
+ )
+ layoutManager = GridLayoutManager(context, columns)
+ adapter = resultAdapter
+ addItemDecoration(object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(outRect: android.graphics.Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
+ outRect.set(previewSpacing / 2, previewSpacing / 2, previewSpacing / 2, previewSpacing / 2)
+ }
+ })
+ }
+
+ loadingView = ProgressBar(context).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ Gravity.CENTER
+ )
+ visibility = View.GONE
+ }
+
+ emptyView = TextView(context).apply {
+ textSize = 14f
+ setTextColor(Color.argb(180, 255, 255, 255))
+ gravity = Gravity.CENTER
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ visibility = View.GONE
+ }
+
+ attributionView = TextView(context).apply {
+ text = context.getString(R.string.gif_picker_powered_by)
+ textSize = 11f
+ setTextColor(Color.argb(160, 255, 255, 255))
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ setMargins(smallPadding, 0, smallPadding, smallPadding / 2)
+ }
+ }
+
+ vertical.addView(searchField)
+ vertical.addView(mediaTypeTabs)
+ vertical.addView(localFolderBar)
+ vertical.addView(recyclerView)
+ vertical.addView(attributionView)
+
+ addView(vertical)
+ addView(loadingView)
+ addView(emptyView)
+ previewOverlay = FrameLayout(context).apply {
+ setBackgroundColor(Color.argb(232, 16, 16, 16))
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ visibility = View.GONE
+ }
+ val previewContent = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ setPadding(dpToPx(12f), dpToPx(12f), dpToPx(12f), dpToPx(12f))
+ }
+ previewImageView = ImageView(context).apply {
+ scaleType = ImageView.ScaleType.FIT_CENTER
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f
+ )
+ setBackgroundColor(Color.argb(35, 255, 255, 255))
+ }
+ previewTitleView = TextView(context).apply {
+ setTextColor(Color.WHITE)
+ textSize = 13f
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = dpToPx(10f)
+ }
+ }
+ val buttonRow = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = dpToPx(12f)
+ }
+ }
+ cancelButton = buildPreviewButton(context.getString(R.string.gif_picker_preview_cancel)).apply {
+ setOnClickListener { hidePreview() }
+ }
+ sendButton = buildPreviewButton(context.getString(R.string.gif_picker_preview_send, selectedMediaType.singularName)).apply {
+ setOnClickListener {
+ selectedGif?.let(onGifSelected)
+ hidePreview()
+ }
+ }
+ buttonRow.addView(cancelButton)
+ buttonRow.addView(sendButton)
+ previewContent.addView(previewImageView)
+ previewContent.addView(previewTitleView)
+ previewContent.addView(buttonRow)
+ previewOverlay.addView(previewContent)
+ addView(previewOverlay)
+
+ val filter = IntentFilter(LocalMediaPickerActivity.ACTION_FOLDER_SELECTED)
+ ContextCompat.registerReceiver(context, localFolderReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ localFolderReceiverRegistered = true
+ updateLocalFolderBar()
+
+ refresh()
+ }
+
+ fun refresh() {
+ ensureActiveScope()
+ hidePreview()
+ if (selectedMediaType != KlipyMediaType.LOCAL && !gifClient.hasConfiguredApiKey()) {
+ showMessage(context.getString(R.string.gif_picker_missing_api_key))
+ return
+ }
+ searchJob?.cancel()
+ if (searchQuery.isBlank()) {
+ applyTrending()
+ return
+ }
+ applySearchNow()
+ }
+
+ fun resetToTrending() {
+ ensureActiveScope()
+ searchJob?.cancel()
+ searchQuery = ""
+ searchField.setText("")
+ hidePreview()
+ showMessage(context.getString(R.string.gif_picker_empty_prompt, selectedMediaType.displayName))
+ applyTrending()
+ }
+
+ fun handleSearchKeyDown(event: KeyEvent): Boolean {
+ if (!searchInputCaptureEnabled) return false
+ if (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) return false
+
+ return when (event.keyCode) {
+ KeyEvent.KEYCODE_DEL -> {
+ val text = searchField.text ?: return true
+ if (text.isEmpty()) return true
+ text.delete(text.length - 1, text.length)
+ true
+ }
+ KeyEvent.KEYCODE_SPACE -> {
+ appendSearchText(" ")
+ true
+ }
+ KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> true
+ else -> {
+ val unicode = event.unicodeChar
+ if (unicode <= 0) return false
+ val ch = unicode.toChar()
+ if (Character.isISOControl(ch)) return false
+ appendSearchText(ch.toString())
+ true
+ }
+ }
+ }
+
+ fun shouldConsumeSearchKeyUp(event: KeyEvent): Boolean {
+ if (!searchInputCaptureEnabled) return false
+ if (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) return false
+ return when (event.keyCode) {
+ KeyEvent.KEYCODE_DEL,
+ KeyEvent.KEYCODE_SPACE,
+ KeyEvent.KEYCODE_ENTER,
+ KeyEvent.KEYCODE_NUMPAD_ENTER -> true
+ else -> {
+ val unicode = event.unicodeChar
+ unicode > 0 && !Character.isISOControl(unicode.toChar())
+ }
+ }
+ }
+
+ fun disableSearchInputCapture() {
+ searchInputCaptureEnabled = false
+ searchField.clearFocus()
+ searchField.alpha = 0.75f
+ }
+
+ fun scrollToTop() {
+ recyclerView.post { recyclerView.scrollToPosition(0) }
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ searchJob?.cancel()
+ coroutineScope.cancel()
+ if (localFolderReceiverRegistered) {
+ runCatching { context.unregisterReceiver(localFolderReceiver) }
+ localFolderReceiverRegistered = false
+ }
+ }
+
+ private fun showPreview(item: KlipyGifResult) {
+ selectedGif = item
+ previewTitleView.text = item.title
+ GifPreviewLoader.loadInto(previewImageView, item.gifUrl)
+ sendButton.text = context.getString(R.string.gif_picker_preview_send, item.mediaType.singularName)
+ previewOverlay.visibility = View.VISIBLE
+ }
+
+ private fun hidePreview() {
+ selectedGif = null
+ previewOverlay.visibility = View.GONE
+ }
+
+ private fun copyGifLink(item: KlipyGifResult) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val fallbackLink = item.shareUrl.ifBlank { item.gifUrl }
+ clipboard.setPrimaryClip(ClipData.newPlainText("${item.mediaType.singularName} link", fallbackLink))
+ Toast.makeText(context, context.getString(R.string.gif_picker_link_copied, item.mediaType.singularName), Toast.LENGTH_SHORT).show()
+ }
+
+ private fun buildMediaTypeTab(type: KlipyMediaType): TextView {
+ return TextView(context).apply {
+ text = type.displayName
+ gravity = Gravity.CENTER
+ textSize = 12f
+ setTextColor(Color.WHITE)
+ setPadding(dpToPx(12f), dpToPx(6f), dpToPx(12f), dpToPx(6f))
+ layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
+ marginStart = dpToPx(2f)
+ marginEnd = dpToPx(2f)
+ }
+ updateMediaTypeTabStyle(this, type == selectedMediaType)
+ setOnClickListener {
+ if (selectedMediaType == type) return@setOnClickListener
+ selectedMediaType = type
+ refreshMediaTypeTabs()
+ updateLocalFolderBar()
+ refresh()
+ }
+ }
+ }
+
+ private fun refreshMediaTypeTabs() {
+ for (index in 0 until mediaTypeTabs.childCount) {
+ val child = mediaTypeTabs.getChildAt(index) as? TextView ?: continue
+ val type = KlipyMediaType.entries.getOrNull(index) ?: continue
+ updateMediaTypeTabStyle(child, type == selectedMediaType)
+ }
+ }
+
+ private fun updateMediaTypeTabStyle(view: TextView, selected: Boolean) {
+ view.setBackgroundColor(if (selected) Color.argb(60, 255, 255, 255) else Color.argb(20, 255, 255, 255))
+ view.alpha = if (selected) 1f else 0.78f
+ }
+
+ private fun updateLocalFolderBar() {
+ val isLocal = selectedMediaType == KlipyMediaType.LOCAL
+ localFolderBar.visibility = if (isLocal) View.VISIBLE else View.GONE
+ if (!isLocal) return
+ val folderUri = localMediaRepository.getSelectedFolderUri()
+ localFolderText.text = if (folderUri == null) {
+ context.getString(R.string.local_media_no_folder)
+ } else {
+ context.getString(R.string.local_media_folder_selected, folderUri.lastPathSegment ?: "folder")
+ }
+ }
+
+ private fun openLocalFolderPicker() {
+ val intent = Intent(context, LocalMediaPickerActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ private fun buildPreviewButton(label: String): TextView {
+ return TextView(context).apply {
+ text = label
+ gravity = Gravity.CENTER
+ setTextColor(Color.WHITE)
+ textSize = 13f
+ setBackgroundColor(Color.argb(38, 255, 255, 255))
+ setPadding(dpToPx(18f), dpToPx(10f), dpToPx(18f), dpToPx(10f))
+ layoutParams = LinearLayout.LayoutParams(
+ 0,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ 1f
+ ).apply {
+ marginStart = dpToPx(4f)
+ marginEnd = dpToPx(4f)
+ }
+ }
+ }
+
+ private fun ensureActiveScope() {
+ if (!coroutineScope.isActive) {
+ coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ }
+ }
+
+ private fun scheduleSearch() {
+ searchJob?.cancel()
+ searchJob = coroutineScope.launch {
+ delay(180)
+ applySearchNow()
+ }
+ }
+
+ private fun appendSearchText(text: String) {
+ val editable = searchField.text ?: return
+ editable.append(text)
+ searchField.setSelection(editable.length)
+ }
+
+ private fun applySearchNow() {
+ if (searchQuery.isBlank()) {
+ applyTrending()
+ return
+ }
+
+ loadingView.visibility = View.VISIBLE
+ emptyView.visibility = View.GONE
+ recyclerView.visibility = View.VISIBLE
+
+ searchJob?.cancel()
+ searchJob = coroutineScope.launch {
+ try {
+ val results = withContext(Dispatchers.IO) {
+ if (selectedMediaType == KlipyMediaType.LOCAL) {
+ localMediaRepository.getItems().filter {
+ it.title.contains(searchQuery, ignoreCase = true)
+ }
+ } else {
+ gifClient.search(searchQuery, mediaType = selectedMediaType)
+ }
+ }
+ loadingView.visibility = View.GONE
+ if (results.isEmpty()) {
+ resultAdapter.submitList(emptyList())
+ showMessage(context.getString(R.string.gif_picker_no_results, selectedMediaType.displayName))
+ } else {
+ emptyView.visibility = View.GONE
+ recyclerView.visibility = View.VISIBLE
+ resultAdapter.submitList(results)
+ scrollToTop()
+ }
+ } catch (e: CancellationException) {
+ loadingView.visibility = View.GONE
+ throw e
+ } catch (_: Exception) {
+ loadingView.visibility = View.GONE
+ resultAdapter.submitList(emptyList())
+ showMessage(context.getString(R.string.gif_picker_error))
+ }
+ }
+ }
+
+ private fun applyTrending() {
+ loadingView.visibility = View.VISIBLE
+ emptyView.visibility = View.GONE
+ recyclerView.visibility = View.VISIBLE
+
+ searchJob?.cancel()
+ searchJob = coroutineScope.launch {
+ try {
+ val results = withContext(Dispatchers.IO) {
+ if (selectedMediaType == KlipyMediaType.LOCAL) {
+ localMediaRepository.getItems()
+ } else {
+ gifClient.trending(mediaType = selectedMediaType)
+ }
+ }
+ loadingView.visibility = View.GONE
+ if (results.isEmpty()) {
+ resultAdapter.submitList(emptyList())
+ val message = if (selectedMediaType == KlipyMediaType.LOCAL && localMediaRepository.getSelectedFolderUri() == null) {
+ context.getString(R.string.local_media_choose_folder_prompt)
+ } else {
+ context.getString(R.string.gif_picker_empty_prompt, selectedMediaType.displayName)
+ }
+ showMessage(message)
+ } else {
+ emptyView.visibility = View.GONE
+ recyclerView.visibility = View.VISIBLE
+ resultAdapter.submitList(results)
+ scrollToTop()
+ }
+ } catch (e: CancellationException) {
+ loadingView.visibility = View.GONE
+ throw e
+ } catch (_: Exception) {
+ loadingView.visibility = View.GONE
+ resultAdapter.submitList(emptyList())
+ showMessage(context.getString(R.string.gif_picker_error))
+ }
+ }
+ }
+
+ private fun showMessage(message: String) {
+ emptyView.text = message
+ emptyView.visibility = View.VISIBLE
+ recyclerView.visibility = View.GONE
+ loadingView.visibility = View.GONE
+ }
+
+ private fun dpToPx(dp: Float): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp,
+ context.resources.displayMetrics
+ ).toInt()
+ }
+}
+
+private class GifResultAdapter(
+ private val onGifTapped: (KlipyGifResult) -> Unit,
+ private val onGifLongPressed: (KlipyGifResult) -> Unit
+) : ListAdapter(GifResultDiffCallback) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GifResultViewHolder {
+ val context = parent.context
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(Color.argb(26, 255, 255, 255))
+ val padding = dpToPx(context, 6f)
+ setPadding(padding, padding, padding, padding)
+ layoutParams = RecyclerView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ }
+ val preview = ImageView(context).apply {
+ scaleType = ImageView.ScaleType.CENTER_CROP
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ dpToPx(context, 72f)
+ )
+ setBackgroundColor(Color.argb(35, 255, 255, 255))
+ }
+ val title = TextView(context).apply {
+ setTextColor(Color.WHITE)
+ textSize = 11f
+ maxLines = 2
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ topMargin = dpToPx(context, 6f)
+ }
+ }
+ container.addView(preview)
+ container.addView(title)
+ return GifResultViewHolder(container, preview, title, onGifTapped, onGifLongPressed)
+ }
+
+ override fun onBindViewHolder(holder: GifResultViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+}
+
+private class GifResultViewHolder(
+ itemView: View,
+ private val previewView: ImageView,
+ private val titleView: TextView,
+ private val onGifTapped: (KlipyGifResult) -> Unit,
+ private val onGifLongPressed: (KlipyGifResult) -> Unit
+) : RecyclerView.ViewHolder(itemView) {
+ fun bind(item: KlipyGifResult) {
+ titleView.text = item.title
+ itemView.setOnClickListener { onGifTapped(item) }
+ itemView.setOnLongClickListener {
+ onGifLongPressed(item)
+ true
+ }
+ GifPreviewLoader.loadInto(previewView, item.previewUrl)
+ }
+}
+
+private object GifResultDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: KlipyGifResult, newItem: KlipyGifResult): Boolean = oldItem.id == newItem.id
+ override fun areContentsTheSame(oldItem: KlipyGifResult, newItem: KlipyGifResult): Boolean = oldItem == newItem
+}
+
+private object GifPreviewLoader {
+ private val okHttpClient = OkHttpClient()
+ private val bitmapCache = object : LruCache((Runtime.getRuntime().maxMemory() / 1024L / 16L).toInt()) {
+ override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024
+ }
+ private val byteCache = object : LruCache(8 * 1024) {
+ override fun sizeOf(key: String, value: ByteArray): Int = value.size / 1024
+ }
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ fun loadInto(imageView: ImageView, url: String) {
+ imageView.tag = url
+ (imageView.drawable as? Animatable)?.stop()
+ if (url.startsWith("content://")) {
+ setLocalPreview(imageView, url)
+ return
+ }
+ byteCache.get(url)?.let { bytes ->
+ setDecodedPreview(imageView, url, bytes)
+ return
+ }
+ bitmapCache.get(url)?.let { bitmap ->
+ imageView.setImageBitmap(bitmap)
+ return
+ }
+ imageView.setImageDrawable(null)
+ scope.launch {
+ val bytes = withContext(Dispatchers.IO) { downloadBytes(url) }
+ if (imageView.tag == url && bytes != null) {
+ byteCache.put(url, bytes)
+ setDecodedPreview(imageView, url, bytes)
+ }
+ }
+ }
+
+ private fun setLocalPreview(imageView: ImageView, url: String) {
+ try {
+ val source = ImageDecoder.createSource(imageView.context.contentResolver, Uri.parse(url))
+ val drawable = ImageDecoder.decodeDrawable(source)
+ if (imageView.tag == url) {
+ imageView.setImageDrawable(drawable)
+ (drawable as? Animatable)?.start()
+ }
+ } catch (_: Exception) {
+ imageView.setImageDrawable(null)
+ }
+ }
+
+ private fun downloadBytes(url: String): ByteArray? {
+ val request = Request.Builder().url(url).build()
+ okHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return null
+ return response.body?.bytes()
+ }
+ }
+
+ private fun setDecodedPreview(imageView: ImageView, url: String, bytes: ByteArray) {
+ val drawable = decodeDrawable(bytes)
+ if (drawable != null) {
+ if (imageView.tag == url) {
+ imageView.setImageDrawable(drawable)
+ (drawable as? Animatable)?.start()
+ }
+ return
+ }
+
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return
+ bitmapCache.put(url, bitmap)
+ if (imageView.tag == url) {
+ imageView.setImageBitmap(bitmap)
+ }
+ }
+
+ private fun decodeDrawable(bytes: ByteArray): Drawable? {
+ return try {
+ val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
+ ImageDecoder.decodeDrawable(source)
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
+
+private fun dpToPx(context: Context, dp: Float): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp,
+ context.resources.displayMetrics
+ ).toInt()
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e31c9cb26..0cdcd6582 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -31,12 +31,18 @@
Spacing & punctuation
Typography
Keyboard behavior
+ Integrations
Delete
Capitalize at text start
Capitalize the first letter when starting in an empty text field.
+
+ Klipy API key
+ Used for GIF search. Leave blank to use the built-in key if this build provides one.
+ Enter API key
+
Capitalize after sentence end
Capitalize the next letter after a period and space.
@@ -1126,6 +1132,24 @@ Each key can be configured as Keycode, Action, Native Ctrl, or None. Use overrid
Open settings
Symbols
Symbols keyboard
+ GIF picker
+ Search and send GIFs from the SYM page cycle.
+ Search Klipy media
+ Powered by Klipy
+ Add your Klipy API key in Text Input settings to use GIF, sticker, and meme search.
+ Trending %1$s will appear here.
+ No %1$s found for that search.
+ Unable to load Klipy results right now.
+ %1$s sent
+ %1$s link copied
+ App does not accept this %1$s from keyboards. Inserted link instead.
+ App does not accept this %1$s from keyboards.
+ Send %1$s
+ Cancel
+ Choose folder
+ No local folder selected.
+ Folder selected: %1$s
+ Choose a local folder to show your own images here.
Undo
Send Ctrl+Z
Redo
diff --git a/app/src/main/res/xml/gif_file_paths.xml b/app/src/main/res/xml/gif_file_paths.xml
new file mode 100644
index 000000000..ec9566fa9
--- /dev/null
+++ b/app/src/main/res/xml/gif_file_paths.xml
@@ -0,0 +1,9 @@
+
+
+
+
+