From 097b0640be351bc7af5d0e2c089ec99043509ed3 Mon Sep 17 00:00:00 2001 From: astroboii47 Date: Thu, 16 Jul 2026 14:24:19 +1000 Subject: [PATCH 1/3] Add Klipy GIF picker page --- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 10 + .../palsoftware/pastiera/SettingsManager.kt | 11 + .../pastiera/SymCustomizationScreen.kt | 43 ++ .../it/palsoftware/pastiera/SymPagesConfig.kt | 5 +- .../pastiera/core/SymLayoutController.kt | 12 +- .../pastiera/gif/GifContentSender.kt | 103 +++++ .../pastiera/gif/KlipyGifClient.kt | 164 +++++++ .../pastiera/gif/KlipyGifResult.kt | 9 + .../inputmethod/CandidatesBarController.kt | 8 + .../PhysicalKeyboardInputMethodService.kt | 47 ++ .../inputmethod/StatusBarController.kt | 31 ++ .../pastiera/inputmethod/ui/GifPickerView.kt | 400 ++++++++++++++++++ app/src/main/res/values/strings.xml | 11 + app/src/main/res/xml/gif_file_paths.xml | 6 + 15 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/it/palsoftware/pastiera/gif/GifContentSender.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt create mode 100644 app/src/main/res/xml/gif_file_paths.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2d5b2323c..fc435cb7a 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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc8d838e0..67f683fb7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -202,6 +202,16 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_provider_paths" /> + + + + diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt index ac6b297b3..983fa8a3c 100644 --- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt +++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt @@ -81,6 +81,7 @@ 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_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" @@ -4187,6 +4188,16 @@ 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() + } /** * 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/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..70d0d5541 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/gif/GifContentSender.kt @@ -0,0 +1,103 @@ +package it.palsoftware.pastiera.gif + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +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" + } + + fun fallbackLink(result: KlipyGifResult): String = if (result.shareUrl.isNotBlank()) result.shareUrl else result.gifUrl + + fun supportsGifCommit(editorInfo: EditorInfo?): Boolean { + val mimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo ?: return false) + return mimeTypes.any { mime -> + mime.equals(GIF_MIME_TYPE, ignoreCase = true) || mime.equals("image/*", ignoreCase = true) + } + } + + fun prepareGif(result: KlipyGifResult): PreparedGifContent { + val sharedDir = File(context.cacheDir, "shared_gifs").apply { mkdirs() } + val safeId = result.id.ifBlank { result.gifUrl.hashCode().toString() }.replace(Regex("[^A-Za-z0-9._-]"), "_") + val outputFile = File(sharedDir, "$safeId.gif") + if (!outputFile.exists() || outputFile.length() == 0L) { + downloadToFile(result.gifUrl, outputFile) + } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.gifprovider", outputFile) + return PreparedGifContent( + uri = uri, + description = ClipDescription(result.title.ifBlank { "GIF" }, arrayOf(GIF_MIME_TYPE)), + linkUri = Uri.parse(fallbackLink(result)) + ) + } + + 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) + clipboard.setPrimaryClip(ClipData.newPlainText("GIF link", fallback)) + } + + private fun downloadToFile(url: String, outputFile: File) { + 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..b7a61a76e --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt @@ -0,0 +1,164 @@ +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, 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, limit, page, locale, useQParam = true), + buildSearchUrl(apiKey, query, limit, page, locale, useQParam = false) + ) + + var lastFailure: Exception? = null + for (url in urls.distinct()) { + try { + return executeSearch(url) + } catch (e: Exception) { + lastFailure = e + } + } + throw lastFailure ?: IllegalStateException("Klipy search failed") + } + + 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, + limit: Int, + page: Int, + locale: String, + useQParam: Boolean + ): HttpUrl { + val builder = HttpUrl.Builder() + .scheme("https") + .host("api.klipy.com") + .addPathSegment("v1") + .addPathSegment(apiKey) + .addPathSegment("gifs") + .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): 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) + } + } + + private fun parseResults(body: String): List { + if (body.isBlank()) return emptyList() + + val root = JSONObject(body) + val items = 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)?.let(results::add) + } + return results + } + + private fun parseResult(item: JSONObject): KlipyGifResult? { + val mediaFormats = item.optJSONObject("media_formats") + val images = item.optJSONObject("images") + + val gifUrl = firstNonBlank( + extractFormatUrl(mediaFormats, listOf("gif", "mediumgif", "tinygif", "nanogif")), + extractImageUrl(images, listOf("original", "fixed_width", "downsized")) + ) ?: return null + + val previewUrl = firstNonBlank( + extractFormatUrl(mediaFormats, listOf("tinygifpreview", "nanogifpreview", "gifpreview", "tinygif", "nanogif")), + extractImageUrl(images, listOf("preview_gif", "fixed_width_still", "downsized_still", "original_still")), + gifUrl + ) ?: return null + + val shareUrl = firstNonBlank( + item.optString("itemurl"), + item.optString("share_url"), + item.optString("url"), + gifUrl + ) ?: gifUrl + + val title = firstNonBlank( + item.optString("title"), + item.optString("content_description"), + item.optString("slug"), + "GIF" + ) ?: "GIF" + + return KlipyGifResult( + id = item.optString("id", shareUrl), + title = title, + previewUrl = previewUrl, + gifUrl = gifUrl, + 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 firstNonBlank(vararg values: String?): String? { + return values.firstOrNull { !it.isNullOrBlank() }?.trim() + } +} 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..265ce2aa3 --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt @@ -0,0 +1,9 @@ +package it.palsoftware.pastiera.gif + +data class KlipyGifResult( + val id: String, + val title: String, + val previewUrl: String, + val gifUrl: String, + val shareUrl: String +) 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..fd60eea24 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,45 @@ 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) { + gifContentSender.copyFallbackLink(result) + Toast.makeText(this, getString(R.string.gif_picker_link_copied), Toast.LENGTH_SHORT).show() + return + } + + CoroutineScope(Dispatchers.Main).launch { + val committed = if (gifContentSender.supportsGifCommit(editorInfo)) { + runCatching { + val preparedGif = withContext(Dispatchers.IO) { + gifContentSender.prepareGif(result) + } + val nonNullEditorInfo = editorInfo ?: return@runCatching false + gifContentSender.commitPreparedGif(preparedGif, inputConnection, nonNullEditorInfo) + }.getOrDefault(false) + } else { + false + } + + if (committed) { + Toast.makeText( + this@PhysicalKeyboardInputMethodService, + getString(R.string.gif_picker_sent), + Toast.LENGTH_SHORT + ).show() + } else { + gifContentSender.insertFallbackLink(result, inputConnection) + Toast.makeText( + this@PhysicalKeyboardInputMethodService, + getString(R.string.gif_picker_fallback_link_inserted), + Toast.LENGTH_SHORT + ).show() + } + } + } + private fun notifyDebugKeyEvent( keyCode: Int, event: KeyEvent?, @@ -1420,6 +1463,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 +1592,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..acfd9e6eb --- /dev/null +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt @@ -0,0 +1,400 @@ +package it.palsoftware.pastiera.inputmethod.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +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.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +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.gif.KlipyGifClient +import it.palsoftware.pastiera.gif.KlipyGifResult +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.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +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 resultAdapter = GifResultAdapter(onGifSelected) + private val fixedHeight = dpToPx(177f) + 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) + + 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() + } + }) + } + + 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(recyclerView) + vertical.addView(attributionView) + + addView(vertical) + addView(loadingView) + addView(emptyView) + + refresh() + } + + fun refresh() { + if (!gifClient.hasConfiguredApiKey()) { + showMessage(context.getString(R.string.gif_picker_missing_api_key)) + return + } + if (searchQuery.isBlank()) { + resultAdapter.submitList(emptyList()) + showMessage(context.getString(R.string.gif_picker_empty_prompt)) + return + } + applySearchNow() + } + + 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() + coroutineScope.cancel() + } + + 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()) { + resultAdapter.submitList(emptyList()) + showMessage(context.getString(R.string.gif_picker_empty_prompt)) + return + } + + loadingView.visibility = View.VISIBLE + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + + searchJob?.cancel() + searchJob = coroutineScope.launch { + try { + val results = withContext(Dispatchers.IO) { + gifClient.search(searchQuery) + } + loadingView.visibility = View.GONE + if (results.isEmpty()) { + resultAdapter.submitList(emptyList()) + showMessage(context.getString(R.string.gif_picker_no_results)) + } else { + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + resultAdapter.submitList(results) + scrollToTop() + } + } catch (e: CancellationException) { + 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 onGifSelected: (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, onGifSelected) + } + + 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 onGifSelected: (KlipyGifResult) -> Unit +) : RecyclerView.ViewHolder(itemView) { + fun bind(item: KlipyGifResult) { + titleView.text = item.title + itemView.setOnClickListener { onGifSelected(item) } + 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun loadInto(imageView: ImageView, url: String) { + imageView.tag = url + bitmapCache.get(url)?.let { + imageView.setImageBitmap(it) + return + } + imageView.setImageDrawable(null) + scope.launch { + val bitmap = withContext(Dispatchers.IO) { downloadBitmap(url) } + if (imageView.tag == url && bitmap != null) { + bitmapCache.put(url, bitmap) + imageView.setImageBitmap(bitmap) + } + } + } + + private fun downloadBitmap(url: String): Bitmap? { + val request = Request.Builder().url(url).build() + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + val bytes = response.body?.bytes() ?: return null + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + } +} + +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..0d12b6522 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1126,6 +1126,17 @@ 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 GIFs + Powered by Klipy + Add a Klipy API key to use GIF search. + Search for a GIF. + No GIFs found. + Could not load GIFs. + GIF sent. + GIF link copied. + GIF link inserted. 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..dd9e7e480 --- /dev/null +++ b/app/src/main/res/xml/gif_file_paths.xml @@ -0,0 +1,6 @@ + + + + From 566475108a56ea14bfa018b6dce94f61a9052d72 Mon Sep 17 00:00:00 2001 From: astroboii47 Date: Sat, 18 Jul 2026 03:27:48 +1000 Subject: [PATCH 2/3] Add Klipy API key setting --- .../pastiera/TextInputSettingsScreen.kt | 57 +++++++++++++++++++ app/src/main/res/values/strings.xml | 6 ++ 2 files changed, 63 insertions(+) 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d12b6522..edb8a01cf 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. From 557256131036f77d5db7cf58d15849d7f70bd423 Mon Sep 17 00:00:00 2001 From: astroboii47 Date: Thu, 16 Jul 2026 14:40:04 +1000 Subject: [PATCH 3/3] Add local media picker support --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 6 + .../pastiera/LocalMediaPickerActivity.kt | 52 +++ .../palsoftware/pastiera/SettingsManager.kt | 13 + .../pastiera/backup/BackupContract.kt | 1 + .../pastiera/gif/GifContentSender.kt | 95 +++- .../pastiera/gif/KlipyGifClient.kt | 114 ++++- .../pastiera/gif/KlipyGifResult.kt | 15 +- .../pastiera/gif/LocalMediaRepository.kt | 41 ++ .../PhysicalKeyboardInputMethodService.kt | 28 +- .../pastiera/inputmethod/ui/GifPickerView.kt | 404 +++++++++++++++++- app/src/main/res/values/strings.xml | 23 +- app/src/main/res/xml/gif_file_paths.xml | 3 + 13 files changed, 726 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/it/palsoftware/pastiera/LocalMediaPickerActivity.kt create mode 100644 app/src/main/java/it/palsoftware/pastiera/gif/LocalMediaRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc435cb7a..fc1af57ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -267,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 67f683fb7..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" /> + + + mime.equals("image/*", ignoreCase = true) || + mime.startsWith("image/", ignoreCase = true) + } + } return mimeTypes.any { mime -> - mime.equals(GIF_MIME_TYPE, ignoreCase = true) || mime.equals("image/*", ignoreCase = true) + mime.equals(result.mimeType, ignoreCase = true) || + (result.mimeType == GIF_MIME_TYPE && mime.equals(GIF_MIME_TYPE, ignoreCase = true)) || + mime.equals("image/*", ignoreCase = true) } } - fun prepareGif(result: KlipyGifResult): PreparedGifContent { - val sharedDir = File(context.cacheDir, "shared_gifs").apply { mkdirs() } + 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 outputFile = File(sharedDir, "$safeId.gif") + val preparedMimeType = preparedMimeType(result) + val extension = extensionForMimeType(preparedMimeType) + val outputFile = File(sharedDir, "$safeId.$extension") if (!outputFile.exists() || outputFile.length() == 0L) { - downloadToFile(result.gifUrl, outputFile) + writeMediaToFile(result, outputFile, preparedMimeType) } val uri = FileProvider.getUriForFile(context, "${context.packageName}.gifprovider", outputFile) return PreparedGifContent( uri = uri, - description = ClipDescription(result.title.ifBlank { "GIF" }, arrayOf(GIF_MIME_TYPE)), - linkUri = Uri.parse(fallbackLink(result)) + description = ClipDescription(result.title.ifBlank { "Media" }, arrayOf(preparedMimeType)), + linkUri = fallbackLink(result).takeIf { it.isNotBlank() }?.let(Uri::parse) ) } @@ -79,10 +97,65 @@ class GifContentSender( fun copyFallbackLink(result: KlipyGifResult) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val fallback = fallbackLink(result) - clipboard.setPrimaryClip(ClipData.newPlainText("GIF link", fallback)) + 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) { @@ -99,5 +172,5 @@ class GifContentSender( data class PreparedGifContent( val uri: Uri, val description: ClipDescription, - val linkUri: Uri + 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 index b7a61a76e..7d7ae6f20 100644 --- a/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt +++ b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifClient.kt @@ -17,20 +17,25 @@ class KlipyGifClient( fun hasConfiguredApiKey(): Boolean = resolveApiKey().isNotBlank() - fun search(query: String, limit: Int = 24, page: Int = 1): List { + 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, limit, page, locale, useQParam = true), - buildSearchUrl(apiKey, query, limit, page, locale, useQParam = false) + 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) + return executeSearch(url, mediaType) } catch (e: Exception) { lastFailure = e } @@ -38,6 +43,31 @@ class KlipyGifClient( 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 @@ -46,6 +76,7 @@ class KlipyGifClient( private fun buildSearchUrl( apiKey: String, query: String, + mediaType: KlipyMediaType, limit: Int, page: Int, locale: String, @@ -54,9 +85,10 @@ class KlipyGifClient( val builder = HttpUrl.Builder() .scheme("https") .host("api.klipy.com") + .addPathSegment("api") .addPathSegment("v1") .addPathSegment(apiKey) - .addPathSegment("gifs") + .addPathSegment(mediaType.apiPathSegment) .addPathSegment("search") .addQueryParameter(if (useQParam) "q" else "query", query) .addQueryParameter("per_page", limit.coerceIn(1, 48).toString()) @@ -66,7 +98,7 @@ class KlipyGifClient( return builder.build() } - private fun executeSearch(url: HttpUrl): List { + private fun executeSearch(url: HttpUrl, mediaType: KlipyMediaType): List { val request = Request.Builder() .url(url) .header("Accept", "application/json") @@ -77,15 +109,17 @@ class KlipyGifClient( throw IllegalStateException("Klipy search failed: ${response.code}") } val body = response.body?.string().orEmpty() - return parseResults(body) + return parseResults(body, mediaType) } } - private fun parseResults(body: String): List { + private fun parseResults(body: String, mediaType: KlipyMediaType): List { if (body.isBlank()) return emptyList() val root = JSONObject(body) - val items = root.optJSONArray("data") + val nestedData = root.optJSONObject("data") + val items = nestedData?.optJSONArray("data") + ?: root.optJSONArray("data") ?: root.optJSONArray("results") ?: root.optJSONArray("gifs") ?: JSONArray() @@ -93,45 +127,63 @@ class KlipyGifClient( val results = mutableListOf() for (index in 0 until items.length()) { val item = items.optJSONObject(index) ?: continue - parseResult(item)?.let(results::add) + parseResult(item, mediaType)?.let(results::add) } return results } - private fun parseResult(item: JSONObject): KlipyGifResult? { + 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 gifUrl = firstNonBlank( + val mediaUrl = firstNonBlank( extractFormatUrl(mediaFormats, listOf("gif", "mediumgif", "tinygif", "nanogif")), - extractImageUrl(images, listOf("original", "fixed_width", "downsized")) + 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")), - gifUrl + 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"), - gifUrl - ) ?: gifUrl + mediaUrl + ) ?: mediaUrl val title = firstNonBlank( item.optString("title"), item.optString("content_description"), item.optString("slug"), - "GIF" - ) ?: "GIF" + 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 = gifUrl, + gifUrl = mediaUrl, + mimeType = mimeType, shareUrl = shareUrl ) } @@ -158,7 +210,31 @@ class KlipyGifClient( 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 index 265ce2aa3..706199a30 100644 --- a/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt +++ b/app/src/main/java/it/palsoftware/pastiera/gif/KlipyGifResult.kt @@ -1,9 +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 shareUrl: 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/PhysicalKeyboardInputMethodService.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt index fd60eea24..4c0400edd 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt @@ -931,19 +931,23 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { val inputConnection = currentInputConnection val editorInfo = currentInputEditorInfo if (inputConnection == null) { - gifContentSender.copyFallbackLink(result) - Toast.makeText(this, getString(R.string.gif_picker_link_copied), Toast.LENGTH_SHORT).show() + 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.supportsGifCommit(editorInfo)) { + val committed = if (gifContentSender.supportsMediaCommit(editorInfo, result)) { runCatching { - val preparedGif = withContext(Dispatchers.IO) { - gifContentSender.prepareGif(result) + val preparedMedia = withContext(Dispatchers.IO) { + gifContentSender.prepareMedia(result) } val nonNullEditorInfo = editorInfo ?: return@runCatching false - gifContentSender.commitPreparedGif(preparedGif, inputConnection, nonNullEditorInfo) + gifContentSender.commitPreparedGif(preparedMedia, inputConnection, nonNullEditorInfo) }.getOrDefault(false) } else { false @@ -952,14 +956,20 @@ class PhysicalKeyboardInputMethodService : InputMethodService() { if (committed) { Toast.makeText( this@PhysicalKeyboardInputMethodService, - getString(R.string.gif_picker_sent), + getString(R.string.gif_picker_sent, result.mediaType.singularName), Toast.LENGTH_SHORT ).show() - } else { + } else if (gifContentSender.hasTextFallback(result)) { gifContentSender.insertFallbackLink(result, inputConnection) Toast.makeText( this@PhysicalKeyboardInputMethodService, - getString(R.string.gif_picker_fallback_link_inserted), + 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() } 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 index acfd9e6eb..07b38aa0d 100644 --- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt +++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/GifPickerView.kt @@ -1,9 +1,15 @@ 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 @@ -13,19 +19,25 @@ 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 @@ -33,10 +45,13 @@ 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, @@ -49,8 +64,19 @@ class GifPickerView( private val loadingView: ProgressBar private val emptyView: TextView private val attributionView: TextView - private val resultAdapter = GifResultAdapter(onGifSelected) - private val fixedHeight = dpToPx(177f) + 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 @@ -58,6 +84,20 @@ class GifPickerView( 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) @@ -96,6 +136,43 @@ class GifPickerView( }) } + 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 @@ -148,29 +225,105 @@ class GifPickerView( } 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() { - if (!gifClient.hasConfiguredApiKey()) { + ensureActiveScope() + hidePreview() + if (selectedMediaType != KlipyMediaType.LOCAL && !gifClient.hasConfiguredApiKey()) { showMessage(context.getString(R.string.gif_picker_missing_api_key)) return } + searchJob?.cancel() if (searchQuery.isBlank()) { - resultAdapter.submitList(emptyList()) - showMessage(context.getString(R.string.gif_picker_empty_prompt)) + 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 @@ -225,7 +378,111 @@ class GifPickerView( 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() { @@ -244,8 +501,7 @@ class GifPickerView( private fun applySearchNow() { if (searchQuery.isBlank()) { - resultAdapter.submitList(emptyList()) - showMessage(context.getString(R.string.gif_picker_empty_prompt)) + applyTrending() return } @@ -257,12 +513,59 @@ class GifPickerView( searchJob = coroutineScope.launch { try { val results = withContext(Dispatchers.IO) { - gifClient.search(searchQuery) + 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()) - showMessage(context.getString(R.string.gif_picker_no_results)) + 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 @@ -270,6 +573,7 @@ class GifPickerView( scrollToTop() } } catch (e: CancellationException) { + loadingView.visibility = View.GONE throw e } catch (_: Exception) { loadingView.visibility = View.GONE @@ -296,7 +600,8 @@ class GifPickerView( } private class GifResultAdapter( - private val onGifSelected: (KlipyGifResult) -> Unit + private val onGifTapped: (KlipyGifResult) -> Unit, + private val onGifLongPressed: (KlipyGifResult) -> Unit ) : ListAdapter(GifResultDiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GifResultViewHolder { @@ -332,7 +637,7 @@ private class GifResultAdapter( } container.addView(preview) container.addView(title) - return GifResultViewHolder(container, preview, title, onGifSelected) + return GifResultViewHolder(container, preview, title, onGifTapped, onGifLongPressed) } override fun onBindViewHolder(holder: GifResultViewHolder, position: Int) { @@ -344,11 +649,16 @@ private class GifResultViewHolder( itemView: View, private val previewView: ImageView, private val titleView: TextView, - private val onGifSelected: (KlipyGifResult) -> Unit + private val onGifTapped: (KlipyGifResult) -> Unit, + private val onGifLongPressed: (KlipyGifResult) -> Unit ) : RecyclerView.ViewHolder(itemView) { fun bind(item: KlipyGifResult) { titleView.text = item.title - itemView.setOnClickListener { onGifSelected(item) } + itemView.setOnClickListener { onGifTapped(item) } + itemView.setOnLongClickListener { + onGifLongPressed(item) + true + } GifPreviewLoader.loadInto(previewView, item.previewUrl) } } @@ -363,30 +673,80 @@ private object GifPreviewLoader { 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 - bitmapCache.get(url)?.let { - imageView.setImageBitmap(it) + (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 bitmap = withContext(Dispatchers.IO) { downloadBitmap(url) } - if (imageView.tag == url && bitmap != null) { - bitmapCache.put(url, bitmap) - imageView.setImageBitmap(bitmap) + val bytes = withContext(Dispatchers.IO) { downloadBytes(url) } + if (imageView.tag == url && bytes != null) { + byteCache.put(url, bytes) + setDecodedPreview(imageView, url, bytes) } } } - private fun downloadBitmap(url: String): Bitmap? { + 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 - val bytes = response.body?.bytes() ?: return null - return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + 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 } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index edb8a01cf..0cdcd6582 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1134,15 +1134,22 @@ Each key can be configured as Keycode, Action, Native Ctrl, or None. Use overrid Symbols keyboard GIF picker Search and send GIFs from the SYM page cycle. - Search GIFs + Search Klipy media Powered by Klipy - Add a Klipy API key to use GIF search. - Search for a GIF. - No GIFs found. - Could not load GIFs. - GIF sent. - GIF link copied. - GIF link inserted. + 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 index dd9e7e480..ec9566fa9 100644 --- a/app/src/main/res/xml/gif_file_paths.xml +++ b/app/src/main/res/xml/gif_file_paths.xml @@ -3,4 +3,7 @@ +