diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a5e84ded1..e41761d4d1 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,7 @@ android { viewBinding = true buildConfig = true compose = true + aidl = true } externalNativeBuild { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 800b0de8a6..92ebc49bdc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -90,6 +90,17 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:theme="@style/TransparentActivityTheme" android:excludeFromRecents="true" android:noHistory="true" android:windowSoftInputMode="adjustResize|stateAlwaysVisible" android:configChanges="orientation|screenSize"/> + + + + + + + @@ -117,6 +128,16 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/gesture_data_path"/> + + + + @@ -132,5 +153,10 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + + + + + diff --git a/app/src/main/aidl/helium314/keyboard/latin/media/IMediaProviderService.aidl b/app/src/main/aidl/helium314/keyboard/latin/media/IMediaProviderService.aidl new file mode 100644 index 0000000000..fbe36b3d17 --- /dev/null +++ b/app/src/main/aidl/helium314/keyboard/latin/media/IMediaProviderService.aidl @@ -0,0 +1,8 @@ +package helium314.keyboard.latin.media; + +interface IMediaProviderService { + Bundle discoverCapabilities(); + Bundle search(String query, in Bundle options); + Bundle browse(String parentId, in Bundle options); + Bundle getContent(String itemId, in Bundle options); +} diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListener.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListener.java index fcafef9cd2..ad0b717a70 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListener.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListener.java @@ -67,6 +67,8 @@ public interface KeyboardActionListener { */ void onTextInput(String text); + void onMediaPickerRequested(); + /** * Called when user started batch input. */ @@ -136,6 +138,8 @@ public void onCodeInput(int primaryCode, int x, int y, boolean isKeyRepeat) {} @Override public void onTextInput(String text) {} @Override + public void onMediaPickerRequested() {} + @Override public void onStartBatchInput() {} @Override public void onUpdateBatchInput(InputPointers batchPointers) {} diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index de842b5387..ded7468755 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -97,6 +97,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { + if (latinIME.consumeMediaPickerCodeInput(primaryCode)) return when (primaryCode) { KeyCode.TOGGLE_AUTOCORRECT -> return settings.toggleAutoCorrect() KeyCode.TOGGLE_INCOGNITO_MODE -> return settings.toggleAlwaysIncognitoMode() @@ -119,7 +120,12 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp metaAfterCodeInput(primaryCode) } - override fun onTextInput(text: String?) = latinIME.onTextInput(text) + override fun onTextInput(text: String?) { + if (latinIME.consumeMediaPickerTextInput(text)) return + latinIME.onTextInput(text) + } + + override fun onMediaPickerRequested() = latinIME.launchMediaPicker() override fun onStartBatchInput() = latinIME.onStartBatchInput() diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index 67d29c0733..37734e3820 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -69,6 +69,7 @@ */ public final class EmojiPalettesView extends LinearLayout implements View.OnClickListener, EmojiViewCallback { + private static final String MEDIA_TAB_TAG = "media"; private static final class PagerViewHolder extends RecyclerView.ViewHolder { private long mCategoryId; @@ -245,6 +246,19 @@ private void addTab(final LinearLayout host, final int categoryId) { iconView.setOnClickListener(this); } + private void addMediaTab(final LinearLayout host) { + final ImageView iconView = new ImageView(getContext()); + mColors.setBackground(iconView, ColorType.STRIP_BACKGROUND); + mColors.setColor(iconView, ColorType.EMOJI_CATEGORY); + iconView.setScaleType(ImageView.ScaleType.CENTER); + iconView.setImageResource(R.drawable.ic_image); + iconView.setContentDescription("Media"); + iconView.setTag(MEDIA_TAB_TAG); + host.addView(iconView); + iconView.setLayoutParams(new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + iconView.setOnClickListener(this); + } + @SuppressLint("ClickableViewAccessibility") public void initialize() { // needs to be delayed for access to EmojiTabStrip, which is not a child of this view if (initialized) return; @@ -254,6 +268,9 @@ public void initialize() { // needs to be delayed for access to EmojiTabStrip, w for (final EmojiCategory.CategoryProperties properties : mEmojiCategory.getShownCategories()) { addTab(mTabStrip, properties.mCategoryId); } + if (Settings.getValues().mMediaPluginsEnabled) { + addMediaTab(mTabStrip); + } } mPager = findViewById(R.id.emoji_pager); @@ -281,6 +298,9 @@ public void onClick(View v) { setCurrentCategoryId(categoryId, false); updateEmojiCategoryPageIdView(); } + } else if (MEDIA_TAB_TAG.equals(tag)) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, this, HapticEvent.KEY_PRESS); + mKeyboardActionListener.onMediaPickerRequested(); } } @@ -336,6 +356,7 @@ public void setHardwareAcceleratedDrawingEnabled(final boolean enabled) { public void startEmojiPalettes(final KeyVisualAttributes keyVisualAttr, final EditorInfo editorInfo, final KeyboardActionListener keyboardActionListener) { initialize(); + mKeyboardActionListener = keyboardActionListener; setupBottomRowKeyboard(editorInfo, keyboardActionListener); final KeyDrawParams params = new KeyDrawParams(); diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 05b6211e8c..b99e6afcd6 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -17,6 +17,7 @@ import android.graphics.Color; import android.inputmethodservice.InputMethodService; import android.media.AudioManager; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Debug; @@ -32,7 +33,9 @@ import android.view.inputmethod.InlineSuggestion; import android.view.inputmethod.InlineSuggestionsRequest; import android.view.inputmethod.InlineSuggestionsResponse; +import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodSubtype; +import android.widget.Toast; import helium314.keyboard.accessibility.AccessibilityUtils; import helium314.keyboard.compat.ConfigurationCompatKt; @@ -62,6 +65,11 @@ import helium314.keyboard.latin.common.ViewOutlineProviderUtilsKt; import helium314.keyboard.latin.define.DebugFlags; import helium314.keyboard.latin.inputlogic.InputLogic; +import helium314.keyboard.latin.media.MediaInsertionController; +import helium314.keyboard.latin.media.MediaInsertionDispatcher; +import helium314.keyboard.latin.media.MediaPluginContract; +import helium314.keyboard.latin.media.provider.MediaPickerPopup; +import helium314.keyboard.latin.media.provider.MediaProviderItem; import helium314.keyboard.latin.personalization.PersonalizationHelper; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; @@ -114,7 +122,6 @@ public class LatinIME extends InputMethodService implements private static final int PENDING_IMS_CALLBACK_DURATION_MILLIS = 800; static final long DELAY_WAIT_FOR_DICTIONARY_LOAD_MILLIS = TimeUnit.SECONDS.toMillis(2); static final long DELAY_DEALLOCATE_MEMORY_MILLIS = TimeUnit.SECONDS.toMillis(10); - /** * The name of the scheme used by the Package Manager to warn of a new package installation, * replacement or removal. @@ -149,6 +156,10 @@ public class LatinIME extends InputMethodService implements // Used for re-initialize keyboard layout after onConfigurationChange. @Nullable private Context mDisplayContext; + private final MediaInsertionController mMediaInsertionController = + new MediaInsertionController(this); + @Nullable + private MediaPickerPopup mActiveMediaPickerPopup; // Object for reacting to adding/removing a dictionary pack. private final BroadcastReceiver mDictionaryPackInstallReceiver = @@ -577,6 +588,7 @@ public void onCreate() { restartAfterUnlockFilter.addAction(Intent.ACTION_USER_UNLOCKED); registerReceiver(mRestartAfterDeviceUnlockReceiver, restartAfterUnlockFilter); + MediaInsertionDispatcher.register(this); StatsUtils.onCreate(mSettings.getCurrent(), mRichImm); } @@ -698,6 +710,7 @@ public void onDestroy() { unregisterReceiver(mDictionaryPackInstallReceiver); unregisterReceiver(mDictionaryDumpBroadcastReceiver); unregisterReceiver(mRestartAfterDeviceUnlockReceiver); + MediaInsertionDispatcher.unregister(this); mStatsUtilsManager.onDestroy(this /* context */); super.onDestroy(); mHandler.removeCallbacksAndMessages(null); @@ -1723,6 +1736,64 @@ public void launchEmojiSearch() { .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_MULTIPLE_TASK)); } + public void launchMediaPicker() { + if (!mSettings.getCurrent().mMediaPluginsEnabled) { + Log.d(MediaPluginContract.LOG_TAG, "Media plugins disabled"); + return; + } + if (mActiveMediaPickerPopup != null) { + mActiveMediaPickerPopup.dismiss(); + return; + } + if (mInputView == null) { + Toast.makeText(this, "Open a text field first", Toast.LENGTH_SHORT).show(); + return; + } + new MediaPickerPopup(this, mInputView, getPreferredMediaMaxBytes()).show(); + } + + public void setActiveMediaPickerPopup(final MediaPickerPopup popup) { + mActiveMediaPickerPopup = popup; + } + + public void clearActiveMediaPickerPopup(final MediaPickerPopup popup) { + if (mActiveMediaPickerPopup == popup) { + mActiveMediaPickerPopup = null; + } + } + + public boolean consumeMediaPickerCodeInput(final int primaryCode) { + return mActiveMediaPickerPopup != null + && mActiveMediaPickerPopup.handleCodeInput(primaryCode); + } + + public boolean consumeMediaPickerTextInput(@Nullable final String text) { + return mActiveMediaPickerPopup != null + && mActiveMediaPickerPopup.handleTextInput(text); + } + + public void onExternalMediaRequested(final Uri uri, final String mime, final String label) { + insertExternalMedia(uri, mime, label); + } + + public void insertExternalMedia(final Uri uri, final String mime, final String label) { + final EditorInfo editorInfo = getCurrentInputEditorInfo(); + mMediaInsertionController.insertMedia(getCurrentInputConnection(), editorInfo, uri, mime, + label, -1, editorInfo == null ? null : editorInfo.packageName, + mMediaInsertionController.getPreferredMediaMaxBytes(editorInfo)); + } + + public void insertExternalMedia(final MediaProviderItem item) { + final EditorInfo editorInfo = getCurrentInputEditorInfo(); + mMediaInsertionController.insertMedia(getCurrentInputConnection(), editorInfo, item, + editorInfo == null ? null : editorInfo.packageName, + mMediaInsertionController.getPreferredMediaMaxBytes(editorInfo)); + } + + private long getPreferredMediaMaxBytes() { + return mMediaInsertionController.getPreferredMediaMaxBytes(getCurrentInputEditorInfo()); + } + @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent != null && EmojiSearchActivity.EMOJI_SEARCH_DONE_ACTION.equals(intent.getAction()) && ! isEmojiSearch()) { diff --git a/app/src/main/java/helium314/keyboard/latin/media/CacheActionSendExporter.java b/app/src/main/java/helium314/keyboard/latin/media/CacheActionSendExporter.java new file mode 100644 index 0000000000..81e13a9b22 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/CacheActionSendExporter.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import androidx.core.content.FileProvider; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.UUID; + +import helium314.keyboard.latin.media.provider.MediaProviderItem; +import helium314.keyboard.latin.utils.Log; + +public final class CacheActionSendExporter { + private static final String CACHE_DIR_NAME = "media_share_cache"; + private static final long EXPORT_TTL_MILLIS = 60L * 60L * 1000L; + private static final int COPY_BUFFER_SIZE = 64 * 1024; + + private CacheActionSendExporter() { + } + + public static Export exportForActionSend(final Context context, final MediaProviderItem item, + final long maxBytes) throws IOException { + if (item == null) { + throw new IOException("Missing media item"); + } + return exportForActionSend(context, item.contentUri, item.mime, item.label, + item.sizeBytes, maxBytes); + } + + public static Export exportForActionSend(final Context context, final Uri sourceUri, + final String mimeType, final String displayName, final long declaredSizeBytes, + final long maxBytes) throws IOException { + if (sourceUri == null || mimeType == null) { + throw new IOException("Missing media source"); + } + cleanupOldExports(context); + final long sizeBytes = declaredSizeBytes >= 0 + ? declaredSizeBytes : getSizeIfKnown(context, sourceUri); + if (sizeBytes > maxBytes) { + throw new IOException("Media exceeds max bytes: " + sizeBytes + " > " + maxBytes); + } + + final File cacheDir = getCacheDir(context); + if (!cacheDir.exists() && !cacheDir.mkdirs()) { + throw new IOException("Could not create media cache directory"); + } + final String safeDisplayName = createGeneratedDisplayName(mimeType); + final File outputFile = new File(cacheDir, safeDisplayName); + boolean success = false; + long copiedBytes = 0; + try (InputStream inputStream = context.getContentResolver().openInputStream(sourceUri); + FileOutputStream outputStream = new FileOutputStream(outputFile)) { + if (inputStream == null) { + throw new IOException("Could not open source media"); + } + final byte[] buffer = new byte[COPY_BUFFER_SIZE]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + copiedBytes += read; + if (copiedBytes > maxBytes) { + throw new IOException("Copied media exceeds max bytes: " + + copiedBytes + " > " + maxBytes); + } + outputStream.write(buffer, 0, read); + } + success = true; + } finally { + if (!success && outputFile.exists() && !outputFile.delete()) { + Log.d(MediaPluginContract.LOG_TAG, + "Could not delete partial cache export: " + outputFile); + } + } + + final Uri uri = FileProvider.getUriForFile(context, + context.getPackageName() + ".mediafileprovider", outputFile); + Log.d(MediaPluginContract.LOG_TAG, "ACTION_SEND cache export used=true" + + " sourceUri=" + sourceUri + + " cacheUri=" + uri + + " mime=" + mimeType + + " size=" + copiedBytes); + return new Export(uri, mimeType, safeDisplayName); + } + + public static void cleanupOldExports(final Context context) { + final File cacheDir = getCacheDir(context); + final File[] files = cacheDir.listFiles(); + if (files == null) { + return; + } + final long cutoff = System.currentTimeMillis() - EXPORT_TTL_MILLIS; + int deleted = 0; + for (final File file : files) { + if (file.isFile() && file.lastModified() < cutoff && file.delete()) { + deleted++; + } + } + Log.d(MediaPluginContract.LOG_TAG, "ACTION_SEND cache cleanup deleted=" + deleted); + } + + private static File getCacheDir(final Context context) { + return new File(context.getCacheDir(), CACHE_DIR_NAME); + } + + private static long getSizeIfKnown(final Context context, final Uri uri) { + try (Cursor cursor = context.getContentResolver().query(uri, + new String[] { OpenableColumns.SIZE }, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + final int index = cursor.getColumnIndex(OpenableColumns.SIZE); + if (index >= 0 && !cursor.isNull(index)) { + return cursor.getLong(index); + } + } + } catch (Throwable t) { + Log.d(MediaPluginContract.LOG_TAG, "Could not query cache export source size: " + t); + } + return -1; + } + + private static String createGeneratedDisplayName(final String mimeType) { + final String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + final String suffix = extension == null || extension.isEmpty() + ? "" : "." + extension.toLowerCase(Locale.ROOT); + return "heliboard_media_" + System.currentTimeMillis() + + "_" + UUID.randomUUID() + suffix; + } + + public static final class Export { + public final Uri uri; + public final String mimeType; + public final String displayName; + + private Export(final Uri uri, final String mimeType, final String displayName) { + this.uri = uri; + this.mimeType = mimeType; + this.displayName = displayName; + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaInsertionController.java b/app/src/main/java/helium314/keyboard/latin/media/MediaInsertionController.java new file mode 100644 index 0000000000..60dd00b71d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaInsertionController.java @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.app.Service; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.Telephony; +import android.telephony.CarrierConfigManager; +import android.telephony.SubscriptionManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.core.view.inputmethod.EditorInfoCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; + +import java.util.Arrays; + +import helium314.keyboard.latin.media.provider.MediaProviderItem; +import helium314.keyboard.latin.settings.Defaults; +import helium314.keyboard.latin.settings.Settings; +import helium314.keyboard.latin.utils.KtxKt; +import helium314.keyboard.latin.utils.Log; + +public final class MediaInsertionController { + private static final int FALLBACK_MIN_MMS_CAP_BYTES = 300 * 1024; + private static final double MMS_MEDIA_SAFETY_FACTOR = 0.85; + + private final Service mService; + + public MediaInsertionController(final Service service) { + mService = service; + } + + public long getPreferredMediaMaxBytes(@Nullable final EditorInfo editorInfo) { + final String defaultSmsPackage = getDefaultSmsPackage(); + final String targetPackage = editorInfo == null ? null : editorInfo.packageName; + if (editorInfo != null && defaultSmsPackage != null + && defaultSmsPackage.equals(editorInfo.packageName)) { + final long maxBytes = Math.max(FALLBACK_MIN_MMS_CAP_BYTES, + (long) (getMmsMaxBytes() * MMS_MEDIA_SAFETY_FACTOR)); + Log.d(MediaPluginContract.LOG_TAG, "media target package=" + targetPackage + + " defaultSmsPackage=" + defaultSmsPackage + + " applying MMS max bytes=" + maxBytes); + return maxBytes; + } + Log.d(MediaPluginContract.LOG_TAG, "media target package=" + targetPackage + + " defaultSmsPackage=" + defaultSmsPackage + + " using unrestricted media size"); + return Long.MAX_VALUE; + } + + public void insertMedia(@Nullable final InputConnection inputConnection, + @Nullable final EditorInfo editorInfo, final MediaProviderItem item, + @Nullable final String targetPackage, final long maxBytes) { + if (item == null) { + Toast.makeText(mService, "Media unavailable", Toast.LENGTH_SHORT).show(); + return; + } + insertMedia(inputConnection, editorInfo, item.contentUri, item.mime, item.label, + item.sizeBytes, targetPackage, maxBytes); + } + + public void insertMedia(@Nullable final InputConnection inputConnection, + @Nullable final EditorInfo editorInfo, final Uri uri, final String mime, + final String label, final long declaredSizeBytes, + @Nullable final String targetPackage, final long maxBytes) { + final String safeLabel = label == null ? MediaPluginContract.DEFAULT_MEDIA_LABEL : label; + if (inputConnection == null || editorInfo == null) { + Log.d(MediaPluginContract.LOG_TAG, "No active input connection for media insertion"); + Toast.makeText(mService, "Open a text field first", Toast.LENGTH_SHORT).show(); + return; + } + if (uri == null || mime == null || !MediaPluginContract.isAcceptedMimeType(mime)) { + Toast.makeText(mService, "Unsupported media type", Toast.LENGTH_SHORT).show(); + return; + } + + try { + inputConnection.finishComposingText(); + } catch (Throwable t) { + Log.w(MediaPluginContract.LOG_TAG, "finishComposingText failed: " + t); + } + + final MediaSizeValidator.Result sizeResult = + MediaSizeValidator.validate(mService, uri, declaredSizeBytes, maxBytes); + if (!sizeResult.valid) { + return; + } + + final String[] supportedMimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo); + Log.d(MediaPluginContract.LOG_TAG, "supported editor MIME types=" + + Arrays.toString(supportedMimeTypes)); + final boolean hasSupportedMimeTypes = + supportedMimeTypes != null && supportedMimeTypes.length > 0; + boolean commitContentResult = false; + if (hasSupportedMimeTypes && editorSupportsMimeType(supportedMimeTypes, mime)) { + final ClipDescription description = new ClipDescription(safeLabel, new String[] { mime }); + final InputContentInfoCompat inputContentInfo = + new InputContentInfoCompat(uri, description, null); + try { + commitContentResult = InputConnectionCompat.commitContent(inputConnection, editorInfo, + inputContentInfo, + InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION, null); + } catch (Throwable t) { + Log.e(MediaPluginContract.LOG_TAG, "commitContent threw", t); + } + Log.d(MediaPluginContract.LOG_TAG, "commitContent result=" + commitContentResult); + } else { + Log.d(MediaPluginContract.LOG_TAG, + hasSupportedMimeTypes + ? "Editor MIME types do not accept " + mime + : "Editor declares no rich-content MIME types"); + } + + if (!commitContentResult) { + launchMediaSendFallback(uri, mime, safeLabel, sizeResult.sizeBytes, maxBytes, + targetPackage); + } + } + + private void launchMediaSendFallback(final Uri uri, final String mime, final String label, + final long mediaSize, final long maxBytes, @Nullable final String targetPackage) { + final String resolvedTargetPackage = getMediaSendTargetPackage(targetPackage); + final boolean privateUriRejectedTarget = + MediaShareTargetCompatibility.isPrivateUriRejectedTarget(resolvedTargetPackage); + if (!privateUriRejectedTarget) { + try { + if (!MediaSizeValidator.validate(mService, uri, mediaSize, maxBytes).valid) { + return; + } + // ACTION_SEND targets are arbitrary external apps. Keep plugin content URIs + // private to the HeliBoard <-> plugin hop by re-hosting through HeliBoard's + // own FileProvider before launching the share intent. + final CacheActionSendExporter.Export cacheExport = + CacheActionSendExporter.exportForActionSend(mService, uri, mime, label, + mediaSize, maxBytes); + if (MediaSizeValidator.validate(mService, cacheExport.uri, mediaSize, maxBytes).valid + && tryLaunchMediaSendFallback(uri, cacheExport.uri, cacheExport.mimeType, + cacheExport.displayName, resolvedTargetPackage, "cache-fileprovider")) { + return; + } + } catch (Throwable t) { + Log.w(MediaPluginContract.LOG_TAG, "ACTION_SEND cache FileProvider fallback failed" + + " sourceUri=" + uri + " mime=" + mime + " size=" + mediaSize, t); + } + } else { + Log.d(MediaPluginContract.LOG_TAG, "Skipping private ACTION_SEND URI fallback for " + + resolvedTargetPackage); + } + + if (!isPublicMediaFallbackEnabled()) { + Log.d(MediaPluginContract.LOG_TAG, "Public MediaStore fallback disabled" + + " targetPackage=" + resolvedTargetPackage + + " privateUriRejectedTarget=" + privateUriRejectedTarget); + Toast.makeText(mService, + "Sharing to this app requires public media fallback. Enable it in HeliBoard settings.", + Toast.LENGTH_SHORT).show(); + return; + } + + if (!MediaSizeValidator.validate(mService, uri, mediaSize, maxBytes).valid) { + return; + } + // Compatibility fallback for MMS/share targets such as AOSP/Graphene Messaging and + // other apps that reject private content:// URIs during ACTION_SEND. This is broader + // than a strict known-app allowlist by design: some failures are only observable after + // private URI methods fail. The explicit setting defaults off because this creates + // temporary public, user-visible MediaStore files; cleanup is best-effort only. + final Uri mediaStoreUri = MediaStoreActionSendExporter.exportForActionSendIfNeeded(mService, + uri, mime, label, mediaSize, maxBytes); + if (!uri.equals(mediaStoreUri) + && MediaSizeValidator.validate(mService, mediaStoreUri, mediaSize, maxBytes).valid + && tryLaunchMediaSendFallback(uri, mediaStoreUri, mime, label, + resolvedTargetPackage, "mediastore-last-resort")) { + return; + } + + Toast.makeText(mService, "No app available to share media", Toast.LENGTH_SHORT).show(); + } + + private boolean isPublicMediaFallbackEnabled() { + return KtxKt.prefs(mService).getBoolean(Settings.PREF_MEDIA_PUBLIC_STORAGE_FALLBACK, + Defaults.PREF_MEDIA_PUBLIC_STORAGE_FALLBACK); + } + + private boolean tryLaunchMediaSendFallback(final Uri sourceUri, final Uri shareUri, + final String mime, final String label, @Nullable final String targetPackage, + final String fallbackKind) { + final Intent send = new Intent(Intent.ACTION_SEND); + send.setType(mime); + send.putExtra(Intent.EXTRA_STREAM, shareUri); + send.setClipData(ClipData.newUri(mService.getContentResolver(), label, shareUri)); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); + + if (targetPackage != null && !mService.getPackageName().equals(targetPackage)) { + send.setPackage(targetPackage); + grantMediaUriToTarget(targetPackage, shareUri); + } + + try { + Log.d(MediaPluginContract.LOG_TAG, "fallback ACTION_SEND sourceUri=" + sourceUri + + " shareUri=" + shareUri + + " kind=" + fallbackKind + + " api=" + Build.VERSION.SDK_INT + + " mime=" + mime + + " targetPackage=" + send.getPackage()); + if (send.getPackage() != null) { + mService.startActivity(send); + } else { + final Intent chooser = Intent.createChooser(send, "Share media"); + chooser.setClipData(send.getClipData()); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_ACTIVITY_NEW_TASK); + mService.startActivity(chooser); + } + Log.d(MediaPluginContract.LOG_TAG, "fallback ACTION_SEND launched sourceUri=" + + sourceUri + " shareUri=" + shareUri + " mime=" + mime + + " kind=" + fallbackKind); + return true; + } catch (ActivityNotFoundException e) { + Log.e(MediaPluginContract.LOG_TAG, "No activity found for ACTION_SEND kind=" + + fallbackKind, e); + return false; + } catch (Throwable t) { + Log.e(MediaPluginContract.LOG_TAG, "ACTION_SEND launch failed kind=" + + fallbackKind, t); + return false; + } + } + + @Nullable + private String getMediaSendTargetPackage(@Nullable final String editorPackage) { + final String defaultSmsPackage = getDefaultSmsPackage(); + if (defaultSmsPackage != null && defaultSmsPackage.equals(editorPackage)) { + return defaultSmsPackage; + } + return editorPackage; + } + + private void grantMediaUriToTarget(final String targetPackage, final Uri uri) { + try { + mService.grantUriPermission(targetPackage, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (Throwable t) { + Log.d(MediaPluginContract.LOG_TAG, "grantUriPermission failed: " + t); + } + } + + private int getMmsMaxBytes() { + try { + final int subId = SubscriptionManager.getDefaultSubscriptionId(); + if (!SubscriptionManager.isValidSubscriptionId(subId)) { + Log.d(MediaPluginContract.LOG_TAG, + "No valid default subscription; using conservative MMS cap"); + return FALLBACK_MIN_MMS_CAP_BYTES; + } + final CarrierConfigManager carrierConfigManager = + (CarrierConfigManager) mService.getSystemService(Context.CARRIER_CONFIG_SERVICE); + if (carrierConfigManager != null) { + final android.os.PersistableBundle bundle = + carrierConfigManager.getConfigForSubId(subId); + if (bundle != null) { + final int value = bundle.getInt( + CarrierConfigManager.KEY_MMS_MAX_MESSAGE_SIZE_INT, + FALLBACK_MIN_MMS_CAP_BYTES); + if (value >= FALLBACK_MIN_MMS_CAP_BYTES && value < 10 * 1024 * 1024) { + return value; + } + } + } + } catch (Throwable t) { + Log.d(MediaPluginContract.LOG_TAG, + "Could not read carrier MMS cap; using conservative fallback: " + t); + } + return FALLBACK_MIN_MMS_CAP_BYTES; + } + + @Nullable + private String getDefaultSmsPackage() { + try { + return Telephony.Sms.getDefaultSmsPackage(mService); + } catch (Throwable t) { + return null; + } + } + + private boolean editorSupportsMimeType(final String[] supportedMimeTypes, final String mime) { + for (final String supportedMimeType : supportedMimeTypes) { + if (ClipDescription.compareMimeTypes(mime, supportedMimeType) + || ClipDescription.compareMimeTypes(supportedMimeType, mime)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaInsertionDispatcher.java b/app/src/main/java/helium314/keyboard/latin/media/MediaInsertionDispatcher.java new file mode 100644 index 0000000000..9485c7548f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaInsertionDispatcher.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.content.Context; +import android.net.Uri; +import android.widget.Toast; + +import java.lang.ref.WeakReference; + +import helium314.keyboard.latin.LatinIME; +import helium314.keyboard.latin.utils.Log; + +public final class MediaInsertionDispatcher { + private static WeakReference sLatinIme = new WeakReference<>(null); + + private MediaInsertionDispatcher() { + } + + public static void register(final LatinIME latinIme) { + sLatinIme = new WeakReference<>(latinIme); + } + + public static void unregister(final LatinIME latinIme) { + final LatinIME registered = sLatinIme.get(); + if (registered == latinIme) { + sLatinIme.clear(); + } + } + + public static boolean dispatch(final Context context, final Uri uri, final String mime, + final String label) { + final LatinIME latinIme = sLatinIme.get(); + if (latinIme == null) { + Log.d(MediaPluginContract.LOG_TAG, "No active IME for media insertion"); + Toast.makeText(context, "Open a text field first", Toast.LENGTH_SHORT).show(); + return false; + } + latinIme.onExternalMediaRequested(uri, mime, label); + return true; + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaPluginContract.java b/app/src/main/java/helium314/keyboard/latin/media/MediaPluginContract.java new file mode 100644 index 0000000000..7521e43873 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaPluginContract.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.content.ClipDescription; + +public final class MediaPluginContract { + /* + * Minimal host-owned plugin contract. + * + * preview_uri must be readable by HeliBoard for thumbnail display. + * content_uri must be readable by HeliBoard and remain stable long enough for + * commitContent() or HeliBoard-owned ACTION_SEND re-hosting. External share targets should + * receive HeliBoard cache/MediaStore URIs, not direct plugin content_uri values. + * + * Providers should honor BUNDLE_MAX_BYTES, but HeliBoard validates size before committing, + * sharing, caching, or exporting media and does not trust ITEM_SIZE_BYTES by itself. + */ + public static final String ACTION_MEDIA_PROVIDER = + "com.heliboard.intent.MEDIA_PROVIDER"; + public static final String ACTION_SEND_MEDIA_TO_IME = + "helium314.keyboard.action.SEND_MEDIA_TO_IME"; + + public static final String EXTRA_MEDIA_URI = "helium314.keyboard.extra.MEDIA_URI"; + public static final String EXTRA_MEDIA_MIME = "helium314.keyboard.extra.MEDIA_MIME"; + public static final String EXTRA_MEDIA_LABEL = "helium314.keyboard.extra.MEDIA_LABEL"; + public static final String EXTRA_MEDIA_MAX_BYTES = + "helium314.keyboard.extra.MEDIA_MAX_BYTES"; + public static final String EXTRA_TARGET_PACKAGE = + "helium314.keyboard.extra.TARGET_PACKAGE"; + + public static final String MIME_IMAGE_GIF = "image/gif"; + public static final String MIME_IMAGE_PNG = "image/png"; + public static final String MIME_IMAGE_JPEG = "image/jpeg"; + public static final String MIME_IMAGE_WEBP = "image/webp"; + public static final String MIME_VIDEO_MP4 = "video/mp4"; + public static final String MIME_FOLDER = "vnd.android.document/directory"; + + public static final String[] ACCEPTED_MIME_TYPES = new String[] { + MIME_IMAGE_GIF, + MIME_IMAGE_PNG, + MIME_IMAGE_JPEG, + MIME_IMAGE_WEBP, + MIME_VIDEO_MP4 + }; + + public static final String DEFAULT_MEDIA_LABEL = "media"; + public static final String LOG_TAG = "MediaPlugin"; + public static final String BUNDLE_SUPPORTS_SEARCH = "supports_search"; + public static final String BUNDLE_SUPPORTS_BROWSE = "supports_browse"; + public static final String BUNDLE_ITEMS = "items"; + public static final String BUNDLE_NEXT_PAGE_TOKEN = "next_page_token"; + public static final String BUNDLE_QUERY = "query"; + public static final String BUNDLE_PAGE_TOKEN = "page_token"; + public static final String BUNDLE_LIMIT = "limit"; + public static final String BUNDLE_MAX_BYTES = "max_bytes"; + public static final String ITEM_ID = "id"; + public static final String ITEM_TITLE = "title"; + public static final String ITEM_MIME = "mime"; + public static final String ITEM_WIDTH = "width"; + public static final String ITEM_HEIGHT = "height"; + public static final String ITEM_SIZE_BYTES = "size_bytes"; + public static final String ITEM_DURATION_MILLIS = "duration_millis"; + public static final String ITEM_PREVIEW_URI = "preview_uri"; + public static final String ITEM_CONTENT_URI = "content_uri"; + public static final String ITEM_LABEL = "label"; + + private MediaPluginContract() { + } + + public static boolean isAcceptedMimeType(final String mimeType) { + if (mimeType == null) { + return false; + } + for (final String accepted : ACCEPTED_MIME_TYPES) { + if (ClipDescription.compareMimeTypes(mimeType, accepted)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaReceiverActivity.java b/app/src/main/java/helium314/keyboard/latin/media/MediaReceiverActivity.java new file mode 100644 index 0000000000..2e64d7150f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaReceiverActivity.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import helium314.keyboard.latin.settings.Defaults; +import helium314.keyboard.latin.settings.Settings; +import helium314.keyboard.latin.utils.KtxKt; +import helium314.keyboard.latin.utils.Log; + +public final class MediaReceiverActivity extends Activity { + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + handleIntent(getIntent()); + finish(); + overridePendingTransition(0, 0); + } + + private void handleIntent(final Intent intent) { + if (intent == null) { + return; + } + final String action = intent.getAction(); + Log.d(MediaPluginContract.LOG_TAG, "received intent action=" + action); + + if (!areMediaPluginsEnabled()) { + Log.d(MediaPluginContract.LOG_TAG, "Ignoring media intent because media plugins are disabled"); + Toast.makeText(this, "Media plugins disabled", Toast.LENGTH_SHORT).show(); + return; + } + + final Uri uri = getMediaUri(intent); + final String mime = getMediaMime(intent, uri); + final String label = getMediaLabel(intent); + Log.d(MediaPluginContract.LOG_TAG, "received uri=" + uri + " mime=" + mime); + + if (uri == null || !ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { + Log.w(MediaPluginContract.LOG_TAG, "Ignoring media intent without content URI"); + Toast.makeText(this, "Invalid media", Toast.LENGTH_SHORT).show(); + return; + } + if (!MediaPluginContract.isAcceptedMimeType(mime)) { + Log.w(MediaPluginContract.LOG_TAG, "Ignoring unsupported media MIME type=" + mime); + Toast.makeText(this, "Unsupported media type", Toast.LENGTH_SHORT).show(); + return; + } + + tryTakePersistableGrant(intent, uri); + MediaInsertionDispatcher.dispatch(this, uri, mime, label); + } + + private boolean areMediaPluginsEnabled() { + return KtxKt.prefs(this).getBoolean(Settings.PREF_ENABLE_MEDIA_PLUGINS, + Defaults.PREF_ENABLE_MEDIA_PLUGINS); + } + + @Nullable + private Uri getMediaUri(final Intent intent) { + final Parcelable extraUri = intent.getParcelableExtra(MediaPluginContract.EXTRA_MEDIA_URI); + if (extraUri instanceof Uri) { + return (Uri) extraUri; + } + final Uri data = intent.getData(); + if (data != null) { + return data; + } + final Parcelable stream = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (stream instanceof Uri) { + return (Uri) stream; + } + return null; + } + + @Nullable + private String getMediaMime(final Intent intent, @Nullable final Uri uri) { + String mime = intent.getStringExtra(MediaPluginContract.EXTRA_MEDIA_MIME); + if (mime == null) { + mime = intent.getType(); + } + if (mime == null && uri != null) { + mime = getContentResolver().getType(uri); + } + return mime; + } + + private String getMediaLabel(final Intent intent) { + String label = intent.getStringExtra(MediaPluginContract.EXTRA_MEDIA_LABEL); + if (label == null) { + label = intent.getStringExtra(Intent.EXTRA_TITLE); + } + return label == null ? MediaPluginContract.DEFAULT_MEDIA_LABEL : label; + } + + private void tryTakePersistableGrant(final Intent intent, final Uri uri) { + final int flags = intent.getFlags() + & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + if ((flags & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) == 0 + || (flags & Intent.FLAG_GRANT_READ_URI_PERMISSION) == 0) { + return; + } + try { + getContentResolver().takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (Throwable t) { + Log.d(MediaPluginContract.LOG_TAG, "takePersistableUriPermission failed: " + t); + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaShareTargetCompatibility.java b/app/src/main/java/helium314/keyboard/latin/media/MediaShareTargetCompatibility.java new file mode 100644 index 0000000000..380d54d53a --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaShareTargetCompatibility.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import androidx.annotation.Nullable; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public final class MediaShareTargetCompatibility { + private static final Set PRIVATE_URI_REJECTED_PACKAGES = new HashSet<>(Arrays.asList( + "com.android.messaging" + )); + + private MediaShareTargetCompatibility() { + } + + public static boolean isPrivateUriRejectedTarget(@Nullable final String packageName) { + return packageName != null && PRIVATE_URI_REJECTED_PACKAGES.contains(packageName); + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaSizeValidator.java b/app/src/main/java/helium314/keyboard/latin/media/MediaSizeValidator.java new file mode 100644 index 0000000000..65f69c4449 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaSizeValidator.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.content.Context; +import android.net.Uri; +import android.widget.Toast; + +import java.io.InputStream; + +import helium314.keyboard.latin.utils.Log; + +public final class MediaSizeValidator { + private static final int COPY_BUFFER_SIZE = 64 * 1024; + + private MediaSizeValidator() { + } + + public static Result validate(final Context context, final Uri uri, + final long declaredSizeBytes, final long maxBytes) { + if (uri == null) { + return reject(context, "Missing media URI"); + } + if (maxBytes < 0) { + return reject(context, "Invalid media size limit"); + } + if (declaredSizeBytes > maxBytes) { + Log.w(MediaPluginContract.LOG_TAG, "Rejecting declared oversized media" + + " uri=" + uri + " size=" + declaredSizeBytes + " maxBytes=" + maxBytes); + return reject(context, "Media is too large"); + } + if (maxBytes == Long.MAX_VALUE) { + return new Result(true, declaredSizeBytes); + } + try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) { + if (inputStream == null) { + Log.w(MediaPluginContract.LOG_TAG, "Media size validation returned null stream" + + " uri=" + uri); + return reject(context, "Media unavailable"); + } + final byte[] buffer = new byte[COPY_BUFFER_SIZE]; + long countedBytes = 0; + int read; + while ((read = inputStream.read(buffer)) != -1) { + countedBytes += read; + if (countedBytes > maxBytes) { + Log.w(MediaPluginContract.LOG_TAG, "Rejecting actual oversized media" + + " uri=" + uri + " size=" + countedBytes + + " declaredSize=" + declaredSizeBytes + + " maxBytes=" + maxBytes); + return reject(context, "Media is too large"); + } + } + Log.d(MediaPluginContract.LOG_TAG, "media size validated" + + " uri=" + uri + " size=" + countedBytes + + " declaredSize=" + declaredSizeBytes + + " maxBytes=" + maxBytes); + return new Result(true, countedBytes); + } catch (Throwable t) { + Log.w(MediaPluginContract.LOG_TAG, "Could not validate media size uri=" + uri, t); + return reject(context, "Media unavailable"); + } + } + + private static Result reject(final Context context, final String toastText) { + Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show(); + return new Result(false, -1); + } + + public static final class Result { + public final boolean valid; + public final long sizeBytes; + + private Result(final boolean valid, final long sizeBytes) { + this.valid = valid; + this.sizeBytes = sizeBytes; + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/MediaStoreActionSendExporter.java b/app/src/main/java/helium314/keyboard/latin/media/MediaStoreActionSendExporter.java new file mode 100644 index 0000000000..4d5149a30e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/MediaStoreActionSendExporter.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import android.webkit.MimeTypeMap; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Locale; + +import helium314.keyboard.latin.utils.DeviceProtectedUtils; +import helium314.keyboard.latin.utils.Log; + +import org.json.JSONArray; +import org.json.JSONObject; + +public final class MediaStoreActionSendExporter { + // Public/user-visible compatibility fallback for apps such as AOSP/Graphene Messaging + // that reject private content:// URIs with "Cannot send private file content://...". + // Callers must gate this behind Settings.PREF_MEDIA_PUBLIC_STORAGE_FALLBACK. Inserted + // MediaStore files require no storage permission on Android Q+; cleanup is best-effort. + private static final String PREF_MEDIASTORE_EXPORTS = "media_plugin_mediastore_exports"; + private static final long EXPORT_TTL_MILLIS = 24L * 60L * 60L * 1000L; + private static final int COPY_BUFFER_SIZE = 64 * 1024; + private static final String IMAGE_RELATIVE_PATH = "Pictures/HeliBoard Shared"; + private static final String VIDEO_RELATIVE_PATH = "Movies/HeliBoard Shared"; + + private MediaStoreActionSendExporter() { + } + + public static Uri exportForActionSendIfNeeded(final Context context, final Uri sourceUri, + final String mimeType, final String displayName, final long declaredSizeBytes, + final long maxBytes) { + cleanupOldExports(context); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.d(MediaPluginContract.LOG_TAG, "ACTION_SEND MediaStore export skipped api=" + + Build.VERSION.SDK_INT + " sourceUri=" + sourceUri); + return sourceUri; + } + if (mimeType == null || sourceUri == null) { + return sourceUri; + } + + final Uri collectionUri = getCollectionUri(mimeType); + if (collectionUri == null) { + Log.d(MediaPluginContract.LOG_TAG, "ACTION_SEND MediaStore export skipped unsupported" + + " mime=" + mimeType + " sourceUri=" + sourceUri); + return sourceUri; + } + + final ContentResolver resolver = context.getContentResolver(); + final String safeDisplayName = ensureExtension(displayName, mimeType); + final MediaSizeValidator.Result sizeResult = + MediaSizeValidator.validate(context, sourceUri, declaredSizeBytes, maxBytes); + if (!sizeResult.valid) { + Log.w(MediaPluginContract.LOG_TAG, "ACTION_SEND MediaStore export skipped oversized" + + " sourceUri=" + sourceUri + + " mime=" + mimeType + + " size=" + sizeResult.sizeBytes + + " maxBytes=" + maxBytes); + return sourceUri; + } + final ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, safeDisplayName); + values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, + mimeType.toLowerCase(Locale.ROOT).startsWith("video/") + ? VIDEO_RELATIVE_PATH : IMAGE_RELATIVE_PATH); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + Uri exportedUri = null; + try { + exportedUri = resolver.insert(collectionUri, values); + if (exportedUri == null) { + Log.w(MediaPluginContract.LOG_TAG, + "ACTION_SEND MediaStore export insert returned null"); + return sourceUri; + } + try (InputStream inputStream = resolver.openInputStream(sourceUri); + OutputStream outputStream = resolver.openOutputStream(exportedUri, "w")) { + if (inputStream == null || outputStream == null) { + throw new IllegalStateException("Could not open MediaStore export streams"); + } + final byte[] buffer = new byte[COPY_BUFFER_SIZE]; + long copiedBytes = 0; + int read; + while ((read = inputStream.read(buffer)) != -1) { + copiedBytes += read; + if (copiedBytes > maxBytes) { + throw new IllegalStateException("MediaStore export exceeds max bytes: " + + copiedBytes + " > " + maxBytes); + } + outputStream.write(buffer, 0, read); + } + } + + final ContentValues complete = new ContentValues(); + complete.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(exportedUri, complete, null, null); + rememberExport(context, exportedUri, safeDisplayName, mimeType); + Log.d(MediaPluginContract.LOG_TAG, "ACTION_SEND MediaStore export used=true" + + " api=" + Build.VERSION.SDK_INT + + " sourceUri=" + sourceUri + + " exportedUri=" + exportedUri + + " mime=" + mimeType + + " size=" + sizeResult.sizeBytes); + return exportedUri; + } catch (Throwable t) { + Log.w(MediaPluginContract.LOG_TAG, "ACTION_SEND MediaStore export failed" + + " sourceUri=" + sourceUri + + " insertedUri=" + exportedUri + + " mime=" + mimeType + + " size=" + sizeResult.sizeBytes, t); + if (exportedUri != null) { + try { + resolver.delete(exportedUri, null, null); + } catch (Throwable deleteError) { + Log.d(MediaPluginContract.LOG_TAG, + "Could not delete failed MediaStore export: " + deleteError); + } + } + return sourceUri; + } + } + + private static Uri getCollectionUri(final String mimeType) { + final String lowerMime = mimeType.toLowerCase(Locale.ROOT); + if (lowerMime.startsWith("image/")) { + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } + if (lowerMime.startsWith("video/")) { + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } + return null; + } + + private static void cleanupOldExports(final Context context) { + final SharedPreferences prefs = DeviceProtectedUtils.getSharedPreferences(context); + final String serialized = prefs.getString(PREF_MEDIASTORE_EXPORTS, "[]"); + final JSONArray retained = new JSONArray(); + int deleted = 0; + final long now = System.currentTimeMillis(); + try { + final JSONArray exports = new JSONArray(serialized); + for (int i = 0; i < exports.length(); i++) { + final JSONObject export = exports.optJSONObject(i); + if (export == null) { + continue; + } + final String uriString = export.optString("uri", null); + final long created = export.optLong("created", 0); + if (uriString == null || created <= 0) { + continue; + } + if (now - created > EXPORT_TTL_MILLIS) { + try { + context.getContentResolver().delete(Uri.parse(uriString), null, null); + deleted++; + } catch (Throwable t) { + retained.put(export); + Log.d(MediaPluginContract.LOG_TAG, + "Could not delete old MediaStore export uri=" + uriString + + " error=" + t); + } + } else { + retained.put(export); + } + } + } catch (Throwable t) { + Log.d(MediaPluginContract.LOG_TAG, + "Could not parse MediaStore export cleanup records: " + t); + } + prefs.edit().putString(PREF_MEDIASTORE_EXPORTS, retained.toString()).apply(); + Log.d(MediaPluginContract.LOG_TAG, "ACTION_SEND MediaStore cleanup deleted=" + deleted); + } + + private static void rememberExport(final Context context, final Uri uri, + final String displayName, final String mimeType) { + final SharedPreferences prefs = DeviceProtectedUtils.getSharedPreferences(context); + JSONArray exports; + try { + exports = new JSONArray(prefs.getString(PREF_MEDIASTORE_EXPORTS, "[]")); + } catch (Throwable t) { + exports = new JSONArray(); + } + final JSONObject export = new JSONObject(); + try { + export.put("uri", uri.toString()); + export.put("created", System.currentTimeMillis()); + export.put("displayName", displayName); + export.put("mimeType", mimeType); + exports.put(export); + prefs.edit().putString(PREF_MEDIASTORE_EXPORTS, exports.toString()).apply(); + } catch (Throwable t) { + Log.d(MediaPluginContract.LOG_TAG, "Could not record MediaStore export: " + t); + } + } + + private static String ensureExtension(final String displayName, final String mimeType) { + final String fallbackName = MediaPluginContract.DEFAULT_MEDIA_LABEL; + final String baseName = displayName == null || displayName.trim().isEmpty() + ? fallbackName : displayName.trim(); + final int slash = baseName.lastIndexOf('/'); + final String sanitized = (slash >= 0 ? baseName.substring(slash + 1) : baseName) + .replaceAll("[\\\\/:*?\"<>|]", "_"); + final int dot = sanitized.lastIndexOf('.'); + if (dot > 0 && dot < sanitized.length() - 1) { + return sanitized; + } + final String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + if (extension == null || extension.isEmpty()) { + return sanitized; + } + return sanitized + "." + extension; + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/provider/MediaPickerPopup.java b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaPickerPopup.java new file mode 100644 index 0000000000..397335f671 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaPickerPopup.java @@ -0,0 +1,893 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media.provider; + +import android.graphics.ImageDecoder; +import android.graphics.drawable.AnimatedImageDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.content.SharedPreferences; +import android.util.LruCache; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupWindow; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import helium314.keyboard.keyboard.KeyboardSwitcher; +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; +import helium314.keyboard.latin.LatinIME; +import helium314.keyboard.latin.R; +import helium314.keyboard.latin.common.ColorType; +import helium314.keyboard.latin.common.Colors; +import helium314.keyboard.latin.common.Constants; +import helium314.keyboard.latin.settings.Settings; + +public final class MediaPickerPopup { + private static final int GRID_SPAN_COUNT = 3; + private static final int SEARCH_PREFETCH_THRESHOLD_ITEMS = 24; + private static final int SEARCH_PREFETCH_TARGET_ITEMS = 72; + private static final String PREFS_NAME = "media_provider_prefs"; + private static final String PREF_SELECTED_PROVIDER = "selected_provider"; + private static final String PREF_SELECTED_MODE = "selected_mode"; + private static final String MODE_SEARCH = "search"; + private static final String MODE_BROWSE = "browse"; + + private final LatinIME mLatinIME; + private final View mAnchor; + private final long mMaxBytes; + private final MediaProviderClient mClient; + private final Colors mColors; + private final ArrayList mItems = new ArrayList<>(); + private final ArrayList mProviders = new ArrayList<>(); + private final ArrayList mProviderChoices = new ArrayList<>(); + private final StringBuilder mQuery = new StringBuilder(); + private final Handler mHandler = new Handler(Looper.getMainLooper()); + private final ThreadPoolExecutor mPreviewExecutor = + new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>()); + private final LruCache mPreviewCache = + new LruCache<>(90); + private boolean mCursorVisible = true; + private PopupWindow mPopupWindow; + private Button mProviderButton; + private Button mSearchButton; + private TextView mQueryView; + private TextView mStatusView; + private MediaAdapter mAdapter; + private MediaProviderInfo mSelectedProvider; + private String mSelectedMode = MODE_SEARCH; + private String mCurrentQuery; + private String mCurrentBrowseParent; + private String mNextPageToken; + private boolean mIsLoadingPage; + private boolean mEndReached; + private boolean mBrowseMode; + private boolean mDismissed; + private final ArrayList mBrowseStack = new ArrayList<>(); + private final Runnable mCursorBlinkRunnable = new Runnable() { + @Override + public void run() { + if (mPopupWindow == null || !mPopupWindow.isShowing()) { + return; + } + mCursorVisible = !mCursorVisible; + updateQueryView(); + mHandler.postDelayed(this, 500); + } + }; + + public MediaPickerPopup(final LatinIME latinIME, final View anchor, final long maxBytes) { + mLatinIME = latinIME; + mAnchor = anchor; + mMaxBytes = maxBytes; + mClient = new MediaProviderClient(latinIME); + mColors = Settings.getValues().mColors; + } + + public void show() { + loadProviders(); + KeyboardSwitcher.getInstance().setAlphabetKeyboard(); + mLatinIME.setActiveMediaPickerPopup(this); + final float density = mAnchor.getResources().getDisplayMetrics().density; + final LinearLayout root = new LinearLayout(mAnchor.getContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(density, 8), dp(density, 8), dp(density, 8), dp(density, 8)); + final GradientDrawable background = new GradientDrawable(); + background.setColor(mColors.get(ColorType.MAIN_BACKGROUND)); + background.setCornerRadius(dp(density, 8)); + root.setBackground(background); + + final LinearLayout searchRow = new LinearLayout(mAnchor.getContext()); + searchRow.setOrientation(LinearLayout.HORIZONTAL); + mQueryView = new TextView(mAnchor.getContext()); + mQueryView.setSingleLine(true); + mQueryView.setTextSize(18); + mQueryView.setGravity(Gravity.CENTER_VERTICAL); + mQueryView.setTextColor(mColors.get(ColorType.KEY_TEXT)); + searchRow.addView(mQueryView, new LinearLayout.LayoutParams(0, dp(density, 48), 1)); + + mSearchButton = new Button(mAnchor.getContext()); + mSearchButton.setText("Search"); + mSearchButton.setTextSize(12); + mSearchButton.setAllCaps(false); + searchRow.addView(mSearchButton, new LinearLayout.LayoutParams(dp(density, 96), dp(density, 48))); + + final Button cancelButton = new Button(mAnchor.getContext()); + cancelButton.setText("Cancel"); + cancelButton.setTextSize(12); + cancelButton.setAllCaps(false); + searchRow.addView(cancelButton, new LinearLayout.LayoutParams(dp(density, 84), dp(density, 48))); + root.addView(searchRow); + + mProviderButton = new Button(mAnchor.getContext()); + mProviderButton.setTextSize(12); + mProviderButton.setAllCaps(false); + mProviderButton.setSingleLine(true); + mProviderButton.setOnClickListener(view -> showProviderChooser()); + root.addView(mProviderButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(density, 40))); + updateProviderButton(); + + mStatusView = new TextView(mAnchor.getContext()); + mStatusView.setText("Search with installed media plugins"); + mStatusView.setTextColor(mColors.get(ColorType.KEY_TEXT)); + mStatusView.setGravity(Gravity.CENTER_VERTICAL); + root.addView(mStatusView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(density, 32))); + + final RecyclerView recyclerView = new RecyclerView(mAnchor.getContext()); + final GridLayoutManager layoutManager = + new GridLayoutManager(mAnchor.getContext(), GRID_SPAN_COUNT); + recyclerView.setLayoutManager(layoutManager); + mAdapter = new MediaAdapter(); + recyclerView.setAdapter(mAdapter); + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + if (dy <= 0 || mItems.isEmpty()) { + return; + } + final int lastVisible = layoutManager.findLastVisibleItemPosition(); + if (lastVisible >= mItems.size() - SEARCH_PREFETCH_THRESHOLD_ITEMS) { + loadNextPage(); + } + } + }); + root.addView(recyclerView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + + mSearchButton.setOnClickListener(view -> { + if (mBrowseMode) { + browseUp(); + } else { + runSearch(mQuery.toString().trim()); + } + }); + cancelButton.setOnClickListener(view -> dismiss()); + + mPopupWindow = new PopupWindow(root, ViewGroup.LayoutParams.MATCH_PARENT, + Math.min(dp(density, 520), Math.max(dp(density, 300), mAnchor.getHeight() / 2)), + false); + mPopupWindow.setClippingEnabled(false); + mPopupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); + mPopupWindow.setOnDismissListener(this::cleanupAfterDismiss); + mPopupWindow.showAtLocation(mAnchor, Gravity.TOP, 0, 0); + updateQueryView(); + mHandler.postDelayed(mCursorBlinkRunnable, 500); + configureSelectedProvider(); + } + + private void loadProviders() { + mProviders.clear(); + mProviders.addAll(mClient.getProviders()); + if (mProviders.isEmpty()) { + mSelectedProvider = null; + return; + } + final SharedPreferences prefs = mLatinIME.getSharedPreferences(PREFS_NAME, 0); + final String selectedKey = prefs.getString(PREF_SELECTED_PROVIDER, null); + mSelectedMode = prefs.getString(PREF_SELECTED_MODE, MODE_SEARCH); + mSelectedProvider = mProviders.get(0); + if (selectedKey != null) { + for (final MediaProviderInfo provider : mProviders) { + if (selectedKey.equals(provider.key)) { + mSelectedProvider = provider; + break; + } + } + } + } + + private void selectProviderChoice(final ProviderChoice choice) { + mSelectedProvider = choice.provider; + mSelectedMode = choice.mode; + mLatinIME.getSharedPreferences(PREFS_NAME, 0).edit() + .putString(PREF_SELECTED_PROVIDER, mSelectedProvider.key) + .putString(PREF_SELECTED_MODE, mSelectedMode).apply(); + mItems.clear(); + mAdapter.notifyDataSetChanged(); + mCurrentQuery = null; + mCurrentBrowseParent = null; + mNextPageToken = null; + mIsLoadingPage = false; + mEndReached = false; + mBrowseStack.clear(); + updateProviderButton(); + configureSelectedProvider(); + } + + private void showProviderChooser() { + discoverProviderChoices(() -> { + if (mProviderChoices.isEmpty()) { + Toast.makeText(mLatinIME, "No media plugin enabled", Toast.LENGTH_SHORT).show(); + return; + } + if (mProviderChoices.size() == 1) { + selectProviderChoice(mProviderChoices.get(0)); + return; + } + final float density = mAnchor.getResources().getDisplayMetrics().density; + final LinearLayout list = new LinearLayout(mAnchor.getContext()); + list.setOrientation(LinearLayout.VERTICAL); + list.setPadding(dp(density, 8), dp(density, 8), dp(density, 8), dp(density, 8)); + final GradientDrawable background = new GradientDrawable(); + background.setColor(mColors.get(ColorType.MAIN_BACKGROUND)); + background.setCornerRadius(dp(density, 8)); + list.setBackground(background); + + final PopupWindow chooser = new PopupWindow(list, + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + false); + chooser.setClippingEnabled(false); + for (final ProviderChoice choice : mProviderChoices) { + final Button button = new Button(mAnchor.getContext()); + button.setText(choice.label()); + button.setTextSize(12); + button.setAllCaps(false); + button.setSingleLine(true); + button.setOnClickListener(view -> { + chooser.dismiss(); + selectProviderChoice(choice); + }); + list.addView(button, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(density, 44))); + } + chooser.showAtLocation(mAnchor, Gravity.TOP, 0, 0); + }); + } + + private void discoverProviderChoices(final Runnable onComplete) { + mProviderChoices.clear(); + if (mProviders.isEmpty()) { + onComplete.run(); + return; + } + mStatusView.setText("Loading media providers..."); + final int[] remaining = new int[] { mProviders.size() }; + for (final MediaProviderInfo provider : mProviders) { + mClient.discoverCapabilities(provider, new MediaProviderClient.CapabilitiesCallback() { + @Override + public void onCapabilities(final MediaProviderInfo providerWithCapabilities) { + addProviderChoices(providerWithCapabilities); + finishProviderDiscovery(remaining, onComplete); + } + + @Override + public void onError(final String message) { + finishProviderDiscovery(remaining, onComplete); + } + }); + } + } + + private void finishProviderDiscovery(final int[] remaining, final Runnable onComplete) { + remaining[0]--; + if (remaining[0] == 0) { + onComplete.run(); + } + } + + private void addProviderChoices(final MediaProviderInfo provider) { + if (provider.supportsSearch) { + mProviderChoices.add(new ProviderChoice(provider, MODE_SEARCH)); + } + if (provider.supportsBrowse) { + mProviderChoices.add(new ProviderChoice(provider, MODE_BROWSE)); + } + } + + private void updateProviderButton() { + if (mProviderButton == null) { + return; + } + if (mSelectedProvider == null) { + mProviderButton.setText("No media plugin enabled"); + mProviderButton.setEnabled(true); + return; + } + mProviderButton.setText("Provider: " + mSelectedProvider.label + " " + + modeLabel(mSelectedMode)); + mProviderButton.setEnabled(true); + } + + public boolean handleCodeInput(final int primaryCode) { + if (mBrowseMode) { + return true; + } + if (primaryCode == KeyCode.DELETE) { + if (mQuery.length() > 0) { + mQuery.deleteCharAt(mQuery.length() - 1); + updateQueryView(); + } + return true; + } + if (primaryCode == Constants.CODE_ENTER) { + runSearch(mQuery.toString().trim()); + return true; + } + if (primaryCode == Constants.CODE_SPACE) { + appendText(" "); + return true; + } + if (primaryCode > 0 && !Character.isISOControl(primaryCode)) { + appendText(new String(Character.toChars(primaryCode))); + return true; + } + return false; + } + + public boolean handleTextInput(@Nullable final String text) { + if (mBrowseMode) { + return true; + } + if (text == null || text.isEmpty()) { + return false; + } + appendText(text); + return true; + } + + private void appendText(final String text) { + mQuery.append(text); + mCursorVisible = true; + updateQueryView(); + } + + private void updateQueryView() { + if (mQueryView != null) { + if (mBrowseMode) { + mQueryView.setText(currentBrowseTitle()); + return; + } + final String cursor = mCursorVisible ? "|" : " "; + mQueryView.setText(mQuery.length() == 0 ? cursor + " Search media" : mQuery + cursor); + } + } + + private void configureSelectedProvider() { + if (mSelectedProvider == null) { + mBrowseMode = false; + mItems.clear(); + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + if (mStatusView != null) { + if (mClient.getDiscoveredProviders().isEmpty()) { + mStatusView.setText("No media plugin installed"); + } else { + mStatusView.setText("Enable a media plugin in HeliBoard settings"); + } + } + updateProviderButton(); + updateModeControls(); + return; + } + mStatusView.setText("Loading " + mSelectedProvider.label + "..."); + mClient.discoverCapabilities(mSelectedProvider, new MediaProviderClient.CapabilitiesCallback() { + @Override + public void onCapabilities(final MediaProviderInfo provider) { + if (!provider.key.equals(mSelectedProvider.key)) { + return; + } + mSelectedProvider = provider; + if (MODE_BROWSE.equals(mSelectedMode) && !provider.supportsBrowse) { + mSelectedMode = provider.supportsSearch ? MODE_SEARCH : MODE_BROWSE; + } else if (MODE_SEARCH.equals(mSelectedMode) && !provider.supportsSearch) { + mSelectedMode = provider.supportsBrowse ? MODE_BROWSE : MODE_SEARCH; + } + mBrowseMode = MODE_BROWSE.equals(mSelectedMode); + mItems.clear(); + mAdapter.notifyDataSetChanged(); + mCurrentQuery = null; + mCurrentBrowseParent = null; + mNextPageToken = null; + mIsLoadingPage = false; + mEndReached = false; + mBrowseStack.clear(); + updateProviderButton(); + updateModeControls(); + if (mBrowseMode) { + browseFolder(null, null); + } else if (provider.supportsSearch) { + mStatusView.setText("Search with " + mSelectedProvider.label); + } else { + mStatusView.setText("Media provider has no usable mode"); + } + } + + @Override + public void onError(final String message) { + mStatusView.setText(message); + Toast.makeText(mLatinIME, message, Toast.LENGTH_SHORT).show(); + } + }); + } + + private void updateModeControls() { + if (mSearchButton == null) { + return; + } + if (mBrowseMode) { + mSearchButton.setText("Up"); + mSearchButton.setEnabled(!mBrowseStack.isEmpty()); + } else { + mSearchButton.setText("Search"); + mSearchButton.setEnabled(true); + } + updateQueryView(); + } + + private void runSearch(final String query) { + if (query.isEmpty()) { + Toast.makeText(mLatinIME, "Enter a search term", Toast.LENGTH_SHORT).show(); + return; + } + mCurrentQuery = query; + mNextPageToken = null; + mIsLoadingPage = false; + mEndReached = false; + mItems.clear(); + mAdapter.notifyDataSetChanged(); + loadPage(false); + } + + private void loadNextPage() { + if (mEndReached || mIsLoadingPage) { + return; + } + if (mBrowseMode) { + browsePage(true); + return; + } + if (mCurrentQuery == null || mCurrentQuery.isEmpty()) { + return; + } + loadPage(true); + } + + private void browseFolder(@Nullable final String parentId, @Nullable final String title) { + mCurrentBrowseParent = parentId; + mNextPageToken = null; + mIsLoadingPage = false; + mEndReached = false; + mItems.clear(); + mAdapter.notifyDataSetChanged(); + if (title != null) { + mBrowseStack.add(new BrowseLocation(parentId, title)); + } + updateModeControls(); + browsePage(false); + } + + private void browseUp() { + if (mBrowseStack.isEmpty()) { + return; + } + mBrowseStack.remove(mBrowseStack.size() - 1); + final BrowseLocation parent = mBrowseStack.isEmpty() + ? null : mBrowseStack.get(mBrowseStack.size() - 1); + mCurrentBrowseParent = parent == null ? null : parent.parentId; + mNextPageToken = null; + mIsLoadingPage = false; + mEndReached = false; + mItems.clear(); + mAdapter.notifyDataSetChanged(); + updateModeControls(); + browsePage(false); + } + + private void browsePage(final boolean append) { + mIsLoadingPage = true; + mStatusView.setText("Loading..."); + final String pageToken = append ? mNextPageToken : null; + mClient.browse(mSelectedProvider, mCurrentBrowseParent, mMaxBytes, pageToken, + new MediaProviderClient.BrowseCallback() { + @Override + public void onResults(final List items, final String nextPageToken) { + mIsLoadingPage = false; + sortBrowseItems(items); + if (!append) { + mItems.clear(); + } + final int insertStart = mItems.size(); + mItems.addAll(items); + if (append) { + mAdapter.notifyItemRangeInserted(insertStart, items.size()); + } else { + mAdapter.notifyDataSetChanged(); + } + mNextPageToken = nextPageToken; + mEndReached = nextPageToken == null || nextPageToken.isEmpty() || items.isEmpty(); + if (mItems.isEmpty()) { + mStatusView.setText("Empty folder"); + } else { + mStatusView.setText(mItems.size() + " item(s)"); + } + } + + @Override + public void onError(final String message) { + mIsLoadingPage = false; + mStatusView.setText(message); + Toast.makeText(mLatinIME, message, Toast.LENGTH_SHORT).show(); + } + }); + } + + private void loadPage(final boolean append) { + mIsLoadingPage = true; + if (!append) { + mStatusView.setText("Searching..."); + } else if (mItems.isEmpty()) { + mStatusView.setText("Loading more..."); + } + final String pageToken = append ? mNextPageToken : null; + mClient.search(mSelectedProvider, mCurrentQuery, mMaxBytes, pageToken, + new MediaProviderClient.SearchCallback() { + @Override + public void onResults(final List items, final String nextPageToken) { + mIsLoadingPage = false; + if (!append) { + mItems.clear(); + } + final List newItems = append + ? withoutExistingItems(items) : items; + final int insertStart = mItems.size(); + mItems.addAll(newItems); + if (append) { + mAdapter.notifyItemRangeInserted(insertStart, newItems.size()); + } else { + mAdapter.notifyDataSetChanged(); + } + mNextPageToken = nextPageToken; + mEndReached = nextPageToken == null || nextPageToken.isEmpty() + || nextPageToken.equals(pageToken) || newItems.isEmpty(); + if (mItems.isEmpty()) { + mStatusView.setText("No results"); + } else if (mEndReached) { + mStatusView.setText(mItems.size() + " results"); + } else { + mStatusView.setText(mItems.size() + " results, more available"); + } + maybePrefetchSearchPage(); + } + + @Override + public void onError(final String message) { + mIsLoadingPage = false; + mStatusView.setText(message); + Toast.makeText(mLatinIME, message, Toast.LENGTH_SHORT).show(); + } + }); + } + + private void maybePrefetchSearchPage() { + if (mBrowseMode || mIsLoadingPage || mEndReached) { + return; + } + if (mItems.size() < SEARCH_PREFETCH_TARGET_ITEMS) { + loadPage(true); + } + } + + private List withoutExistingItems(final List items) { + final ArrayList filtered = new ArrayList<>(); + for (final MediaProviderItem item : items) { + if (!hasItem(item.id)) { + filtered.add(item); + } + } + return filtered; + } + + private boolean hasItem(final String id) { + for (final MediaProviderItem item : mItems) { + if (item.id.equals(id)) { + return true; + } + } + return false; + } + + private void selectItem(final MediaProviderItem item) { + if (item.isFolder) { + browseFolder(item.id, item.title == null ? "Folder" : item.title); + return; + } + mStatusView.setText("Loading media..."); + mClient.getContent(mSelectedProvider, item.id, mMaxBytes, new MediaProviderClient.ContentCallback() { + @Override + public void onContent(final MediaProviderItem contentItem) { + dismiss(); + mLatinIME.insertExternalMedia(contentItem); + } + + @Override + public void onError(final String message) { + mStatusView.setText(message); + Toast.makeText(mLatinIME, message, Toast.LENGTH_SHORT).show(); + } + }); + } + + public void dismiss() { + if (mPopupWindow != null && mPopupWindow.isShowing()) { + mPopupWindow.dismiss(); + } + cleanupAfterDismiss(); + } + + private void cleanupAfterDismiss() { + if (mDismissed) { + return; + } + mDismissed = true; + mHandler.removeCallbacks(mCursorBlinkRunnable); + mClient.close(); + mPreviewExecutor.shutdownNow(); + mLatinIME.clearActiveMediaPickerPopup(this); + } + + private int dp(final float density, final int value) { + return (int) (value * density + 0.5f); + } + + private final class MediaAdapter extends RecyclerView.Adapter { + @Override + public MediaViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { + final ImageView imageView = new ImageView(parent.getContext()); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + imageView.setAdjustViewBounds(false); + imageView.setBackgroundColor(mColors.get(ColorType.KEY_BACKGROUND)); + final int size = parent.getResources().getDisplayMetrics().widthPixels / GRID_SPAN_COUNT; + final LinearLayout container = new LinearLayout(parent.getContext()); + container.setOrientation(LinearLayout.VERTICAL); + container.setBackgroundColor(mColors.get(ColorType.KEY_BACKGROUND)); + container.setLayoutParams(new RecyclerView.LayoutParams(size, size)); + imageView.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + final TextView titleView = new TextView(parent.getContext()); + titleView.setSingleLine(true); + titleView.setGravity(Gravity.CENTER); + titleView.setTextSize(11); + titleView.setTextColor(mColors.get(ColorType.KEY_TEXT)); + titleView.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp( + parent.getResources().getDisplayMetrics().density, 24))); + container.addView(imageView); + container.addView(titleView); + return new MediaViewHolder(container, imageView, titleView); + } + + @Override + public void onBindViewHolder(final MediaViewHolder holder, final int position) { + final MediaProviderItem item = mItems.get(position); + cancelPreview(holder); + holder.imageView.setTag(item.id); + holder.previewKey = null; + holder.imageView.setImageDrawable(null); + holder.imageView.setImageURI(null); + if (item.title == null || item.title.isEmpty()) { + holder.titleView.setVisibility(View.GONE); + holder.titleView.setText(""); + } else { + holder.titleView.setVisibility(View.VISIBLE); + holder.titleView.setText(item.title); + } + if (item.isFolder) { + holder.imageView.setImageDrawable(folderDrawable()); + } else if (item.previewUri != null) { + loadPreview(holder, item); + } + holder.itemView.setOnClickListener(view -> selectItem(item)); + } + + @Override + public int getItemCount() { + return mItems.size(); + } + + private void loadPreview(final MediaViewHolder holder, final MediaProviderItem item) { + final String previewKey = item.previewUri.toString(); + final String cacheKey = previewCacheKey(item); + holder.previewKey = previewKey; + final Drawable cachedDrawable = cachedPreviewDrawable(cacheKey); + if (cachedDrawable != null) { + holder.imageView.setImageDrawable(cachedDrawable); + startIfAnimated(cachedDrawable); + return; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + holder.imageView.setImageURI(item.previewUri); + return; + } + holder.previewFuture = mPreviewExecutor.submit(() -> { + try { + if (Thread.currentThread().isInterrupted() + || !previewKey.equals(holder.previewKey)) { + return; + } + final ImageDecoder.Source source = + ImageDecoder.createSource(mLatinIME.getContentResolver(), item.previewUri); + final Drawable drawable = ImageDecoder.decodeDrawable(source); + mHandler.post(() -> { + if (!previewKey.equals(holder.previewKey)) { + return; + } + final Drawable.ConstantState state = drawable.getConstantState(); + if (state != null) { + mPreviewCache.put(cacheKey, state); + } + holder.imageView.setImageDrawable(drawable); + startIfAnimated(drawable); + }); + } catch (Throwable ignored) { + mHandler.post(() -> { + if (previewKey.equals(holder.previewKey)) { + holder.imageView.setImageDrawable(null); + } + }); + } + }); + } + + @Override + public void onViewRecycled(final MediaViewHolder holder) { + cancelPreview(holder); + holder.previewKey = null; + holder.imageView.setImageDrawable(null); + holder.imageView.setImageURI(null); + super.onViewRecycled(holder); + } + + private void cancelPreview(final MediaViewHolder holder) { + if (holder.previewFuture != null) { + holder.previewFuture.cancel(true); + holder.previewFuture = null; + mPreviewExecutor.purge(); + } + } + + private Drawable cachedPreviewDrawable(final String cacheKey) { + final Drawable.ConstantState state = mPreviewCache.get(cacheKey); + return state == null ? null : state.newDrawable(); + } + + private void startIfAnimated(final Drawable drawable) { + if (drawable instanceof AnimatedImageDrawable) { + ((AnimatedImageDrawable) drawable).start(); + } + } + } + + private static final class MediaViewHolder extends RecyclerView.ViewHolder { + final ImageView imageView; + final TextView titleView; + String previewKey; + Future previewFuture; + + MediaViewHolder(final View itemView, final ImageView imageView, final TextView titleView) { + super(itemView); + this.imageView = imageView; + this.titleView = titleView; + } + } + + private Drawable folderDrawable() { + final Drawable drawable = ContextCompat.getDrawable(mLatinIME, R.drawable.ic_media_folder); + if (drawable != null) { + drawable.mutate().setTint(mColors.get(ColorType.KEY_TEXT)); + } + return drawable; + } + + private void sortBrowseItems(final List items) { + Collections.sort(items, new Comparator() { + @Override + public int compare(final MediaProviderItem left, final MediaProviderItem right) { + if (left.isFolder != right.isFolder) { + return left.isFolder ? -1 : 1; + } + return normalizedTitle(left).compareTo(normalizedTitle(right)); + } + }); + } + + private String normalizedTitle(final MediaProviderItem item) { + final String title = item.title == null || item.title.isEmpty() ? item.id : item.title; + return title == null ? "" : title.toLowerCase(Locale.ROOT); + } + + private String previewCacheKey(final MediaProviderItem item) { + final String scope = mBrowseMode ? nullToEmpty(mCurrentBrowseParent) + : nullToEmpty(mCurrentQuery); + return mSelectedProvider.key + "|" + mSelectedMode + "|" + scope + "|" + + item.previewUri; + } + + private String nullToEmpty(@Nullable final String value) { + return value == null ? "" : value; + } + + private String currentBrowseTitle() { + if (mBrowseStack.isEmpty()) { + return "Browse media"; + } + return mBrowseStack.get(mBrowseStack.size() - 1).title; + } + + private String modeLabel(final String mode) { + return MODE_BROWSE.equals(mode) ? "Browse" : "Search"; + } + + private static final class BrowseLocation { + final String parentId; + final String title; + + BrowseLocation(final String parentId, final String title) { + this.parentId = parentId; + this.title = title; + } + } + + private final class ProviderChoice { + final MediaProviderInfo provider; + final String mode; + + ProviderChoice(final MediaProviderInfo provider, final String mode) { + this.provider = provider; + this.mode = mode; + } + + String label() { + return provider.label + " " + modeLabel(mode); + } + } + +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/provider/MediaPluginApprovalStore.java b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaPluginApprovalStore.java new file mode 100644 index 0000000000..806a5ae207 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaPluginApprovalStore.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media.provider; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public final class MediaPluginApprovalStore { + private static final String PREFS_NAME = "media_provider_prefs"; + private static final String PREF_ENABLED_PROVIDERS = "enabled_providers"; + + private MediaPluginApprovalStore() { + } + + public static boolean isEnabled(final Context context, final MediaProviderInfo provider) { + return enabledKeys(context).contains(provider.key); + } + + public static void setEnabled(final Context context, final MediaProviderInfo provider, + final boolean enabled) { + final HashSet keys = new HashSet<>(enabledKeys(context)); + if (enabled) { + keys.add(provider.key); + } else { + keys.remove(provider.key); + } + prefs(context).edit().putStringSet(PREF_ENABLED_PROVIDERS, keys).apply(); + } + + public static Set enabledKeys(final Context context) { + final Set keys = prefs(context).getStringSet(PREF_ENABLED_PROVIDERS, + Collections.emptySet()); + return keys == null ? Collections.emptySet() : new HashSet<>(keys); + } + + static boolean isKnownEnabledKey(final Context context, final String key) { + return enabledKeys(context).contains(key); + } + + private static SharedPreferences prefs(final Context context) { + return context.getApplicationContext() + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderClient.java b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderClient.java new file mode 100644 index 0000000000..8c55eac1e3 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderClient.java @@ -0,0 +1,420 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media.provider; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import helium314.keyboard.latin.media.IMediaProviderService; +import helium314.keyboard.latin.media.MediaPluginContract; + +public final class MediaProviderClient { + public interface CapabilitiesCallback { + void onCapabilities(MediaProviderInfo provider); + void onError(String message); + } + + public interface SearchCallback { + void onResults(List items, String nextPageToken); + void onError(String message); + } + + public interface ContentCallback { + void onContent(MediaProviderItem item); + void onError(String message); + } + + public interface BrowseCallback { + void onResults(List items, String nextPageToken); + void onError(String message); + } + + private final Context mContext; + private final Handler mHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(); + private final Set mConnections = + Collections.synchronizedSet(new HashSet<>()); + private volatile boolean mClosed; + private static final long PLUGIN_TIMEOUT_MILLIS = 8000L; + + public MediaProviderClient(final Context context) { + mContext = context.getApplicationContext(); + } + + public boolean hasProviders() { + return !getProviders().isEmpty(); + } + + public List getProviders() { + final ArrayList enabled = new ArrayList<>(); + for (final MediaProviderInfo provider : getDiscoveredProviders()) { + if (MediaPluginApprovalStore.isEnabled(mContext, provider)) { + enabled.add(provider); + } + } + return enabled; + } + + public List getDiscoveredProviders() { + final ArrayList providers = new ArrayList<>(); + final PackageManager packageManager = mContext.getPackageManager(); + for (final ResolveInfo resolveInfo : queryProviders()) { + if (resolveInfo.serviceInfo == null) { + continue; + } + final String packageName = resolveInfo.serviceInfo.packageName; + final String serviceName = resolveInfo.serviceInfo.name; + final CharSequence label = resolveInfo.loadLabel(packageManager); + providers.add(new MediaProviderInfo(packageName + "/" + serviceName, + label == null ? packageName : label.toString(), packageName, serviceName)); + } + return providers; + } + + public void close() { + if (mClosed) { + return; + } + mClosed = true; + synchronized (mConnections) { + for (final ServiceConnection connection : new ArrayList<>(mConnections)) { + safeUnbind(connection); + } + mConnections.clear(); + } + mExecutor.shutdownNow(); + } + + public void discoverCapabilities(final MediaProviderInfo provider, + final CapabilitiesCallback callback) { + bindProvider(provider, new BoundAction() { + @Override + public void run(final IMediaProviderService service, final ServiceConnection connection) { + if (!tryExecute(() -> { + try { + final Bundle capabilities = service.discoverCapabilities(); + final boolean supportsSearch = capabilities == null + || capabilities.getBoolean( + MediaPluginContract.BUNDLE_SUPPORTS_SEARCH, true); + final boolean supportsBrowse = capabilities != null + && capabilities.getBoolean( + MediaPluginContract.BUNDLE_SUPPORTS_BROWSE, false); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onCapabilities( + provider.withCapabilities(supportsSearch, supportsBrowse))); + } + } catch (RemoteException | RuntimeException e) { + Log.e(MediaPluginContract.LOG_TAG, + "media provider capabilities failed", e); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onError("Media plugin unavailable")); + } + } finally { + safeUnbind(connection); + } + }, "Media plugin unavailable", callback::onError)) { + safeUnbind(connection); + } + } + + @Override + public void error(final String message) { + callback.onError(message); + } + }); + } + + public void search(final MediaProviderInfo provider, final String query, final long maxBytes, + final String pageToken, + final SearchCallback callback) { + bindProvider(provider, new BoundAction() { + @Override + public void run(final IMediaProviderService service, final ServiceConnection connection) { + if (!tryExecute(() -> { + try { + final Bundle options = new Bundle(); + options.putInt(MediaPluginContract.BUNDLE_LIMIT, 24); + options.putLong(MediaPluginContract.BUNDLE_MAX_BYTES, maxBytes); + if (pageToken != null && !pageToken.isEmpty()) { + options.putString(MediaPluginContract.BUNDLE_PAGE_TOKEN, pageToken); + } + final Bundle result = service.search(query, options); + final List items = parseItems(result); + final String nextPageToken = result == null ? null + : result.getString(MediaPluginContract.BUNDLE_NEXT_PAGE_TOKEN); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onResults(items, nextPageToken)); + } + } catch (RemoteException | RuntimeException e) { + Log.e(MediaPluginContract.LOG_TAG, "media provider search failed", e); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onError("Search failed")); + } + } finally { + safeUnbind(connection); + } + }, "Search failed", callback::onError)) { + safeUnbind(connection); + } + } + + @Override + public void error(final String message) { + callback.onError(message); + } + }); + } + + public void getContent(final MediaProviderInfo provider, final String itemId, final long maxBytes, + final ContentCallback callback) { + bindProvider(provider, new BoundAction() { + @Override + public void run(final IMediaProviderService service, final ServiceConnection connection) { + if (!tryExecute(() -> { + try { + final Bundle options = new Bundle(); + options.putLong(MediaPluginContract.BUNDLE_MAX_BYTES, maxBytes); + final MediaProviderItem item = + MediaProviderItem.fromBundle(service.getContent(itemId, options)); + if (item == null || item.contentUri == null) { + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onError("No media returned")); + } + } else { + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onContent(item)); + } + } + } catch (RemoteException | RuntimeException e) { + Log.e(MediaPluginContract.LOG_TAG, "media provider content failed", e); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onError("Download failed")); + } + } finally { + safeUnbind(connection); + } + }, "Download failed", callback::onError)) { + safeUnbind(connection); + } + } + + @Override + public void error(final String message) { + callback.onError(message); + } + }); + } + + public void browse(final MediaProviderInfo provider, @Nullable final String parentId, + final long maxBytes, final String pageToken, final BrowseCallback callback) { + bindProvider(provider, new BoundAction() { + @Override + public void run(final IMediaProviderService service, final ServiceConnection connection) { + if (!tryExecute(() -> { + try { + final Bundle options = new Bundle(); + options.putInt(MediaPluginContract.BUNDLE_LIMIT, 100); + options.putLong(MediaPluginContract.BUNDLE_MAX_BYTES, maxBytes); + if (pageToken != null && !pageToken.isEmpty()) { + options.putString(MediaPluginContract.BUNDLE_PAGE_TOKEN, pageToken); + } + final Bundle result = service.browse(parentId, options); + final List items = parseItems(result); + final String nextPageToken = result == null ? null + : result.getString(MediaPluginContract.BUNDLE_NEXT_PAGE_TOKEN); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onResults(items, nextPageToken)); + } + } catch (RemoteException | RuntimeException e) { + Log.e(MediaPluginContract.LOG_TAG, "media provider browse failed", e); + if (!isConnectionFinished(connection)) { + mHandler.post(() -> callback.onError("Browse failed")); + } + } finally { + safeUnbind(connection); + } + }, "Browse failed", callback::onError)) { + safeUnbind(connection); + } + } + + @Override + public void error(final String message) { + callback.onError(message); + } + }); + } + + private List queryProviders() { + final Intent intent = new Intent(MediaPluginContract.ACTION_MEDIA_PROVIDER); + return mContext.getPackageManager().queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY); + } + + private void bindProvider(final MediaProviderInfo provider, final BoundAction action) { + if (mClosed) { + action.error("Media plugin unavailable"); + return; + } + final MediaProviderInfo resolvedProvider; + if (provider == null) { + final List providers = getProviders(); + if (providers.isEmpty()) { + action.error("No media plugin installed"); + return; + } + resolvedProvider = providers.get(0); + } else { + resolvedProvider = provider; + } + if (resolvedProvider == null) { + action.error("No media plugin installed"); + return; + } + if (!MediaPluginApprovalStore.isEnabled(mContext, resolvedProvider)) { + action.error("Media plugin disabled"); + return; + } + final Intent intent = new Intent(MediaPluginContract.ACTION_MEDIA_PROVIDER); + intent.setClassName(resolvedProvider.packageName, resolvedProvider.serviceName); + final AtomicBoolean finished = new AtomicBoolean(false); + final ServiceConnection connection = new ServiceConnection() { + @Override + public void onServiceConnected(final ComponentName name, final IBinder binder) { + if (mClosed || finished.get()) { + safeUnbind(this); + return; + } + action.run(IMediaProviderService.Stub.asInterface(binder), + new TimeoutServiceConnection(this, finished)); + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + } + }; + mConnections.add(connection); + mHandler.postDelayed(() -> { + if (finished.compareAndSet(false, true)) { + safeUnbind(connection); + action.error("Media plugin timed out"); + } + }, PLUGIN_TIMEOUT_MILLIS); + if (!mContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)) { + finished.set(true); + safeUnbind(connection); + action.error("Could not bind media plugin"); + } + } + + private List parseItems(final Bundle result) { + final ArrayList items = new ArrayList<>(); + if (result == null) { + return items; + } + final ArrayList bundles = + result.getParcelableArrayList(MediaPluginContract.BUNDLE_ITEMS); + if (bundles == null) { + return items; + } + for (final Bundle bundle : bundles) { + final MediaProviderItem item = MediaProviderItem.fromBundle(bundle); + if (item != null) { + items.add(item); + } + } + return items; + } + + private void safeUnbind(final ServiceConnection connection) { + if (connection instanceof TimeoutServiceConnection) { + ((TimeoutServiceConnection) connection).finish(); + return; + } + mConnections.remove(connection); + try { + mContext.unbindService(connection); + } catch (IllegalArgumentException ignored) { + } + } + + private boolean tryExecute(final Runnable runnable, final String errorMessage, + final ErrorCallback callback) { + if (mClosed) { + callback.onError("Media plugin unavailable"); + return false; + } + try { + mExecutor.execute(runnable); + return true; + } catch (RejectedExecutionException e) { + callback.onError(errorMessage); + return false; + } + } + + private boolean isConnectionFinished(final ServiceConnection connection) { + return connection instanceof TimeoutServiceConnection + && ((TimeoutServiceConnection) connection).isFinished(); + } + + private interface BoundAction { + void run(IMediaProviderService service, ServiceConnection connection); + void error(String message); + } + + private interface ErrorCallback { + void onError(String message); + } + + private final class TimeoutServiceConnection implements ServiceConnection { + private final ServiceConnection mDelegate; + private final AtomicBoolean mFinished; + + TimeoutServiceConnection(final ServiceConnection delegate, final AtomicBoolean finished) { + mDelegate = delegate; + mFinished = finished; + } + + @Override + public void onServiceConnected(final ComponentName name, final IBinder service) { + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + } + + void finish() { + mFinished.set(true); + safeUnbind(mDelegate); + } + + boolean isFinished() { + return mFinished.get(); + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderInfo.java b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderInfo.java new file mode 100644 index 0000000000..2ea0429bf5 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderInfo.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media.provider; + +public final class MediaProviderInfo { + public final String key; + public final String label; + public final String packageName; + public final String serviceName; + public final boolean supportsSearch; + public final boolean supportsBrowse; + + public MediaProviderInfo(final String key, final String label, final String packageName, + final String serviceName) { + this(key, label, packageName, serviceName, true, false); + } + + public MediaProviderInfo(final String key, final String label, final String packageName, + final String serviceName, final boolean supportsSearch, final boolean supportsBrowse) { + this.key = key; + this.label = label; + this.packageName = packageName; + this.serviceName = serviceName; + this.supportsSearch = supportsSearch; + this.supportsBrowse = supportsBrowse; + } + + public MediaProviderInfo withCapabilities(final boolean supportsSearch, + final boolean supportsBrowse) { + return new MediaProviderInfo(key, label, packageName, serviceName, + supportsSearch, supportsBrowse); + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderItem.java b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderItem.java new file mode 100644 index 0000000000..45a8f7aedc --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/media/provider/MediaProviderItem.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.latin.media.provider; + +import android.net.Uri; +import android.os.Bundle; + +import helium314.keyboard.latin.media.MediaPluginContract; + +public final class MediaProviderItem { + public final String id; + public final String title; + public final String mime; + public final String label; + public final Uri previewUri; + public final Uri contentUri; + public final long sizeBytes; + public final boolean isFolder; + + private MediaProviderItem(final String id, final String title, final String mime, + final String label, final Uri previewUri, final Uri contentUri, final long sizeBytes, + final boolean isFolder) { + this.id = id; + this.title = title; + this.mime = mime; + this.label = label; + this.previewUri = previewUri; + this.contentUri = contentUri; + this.sizeBytes = sizeBytes; + this.isFolder = isFolder; + } + + public static MediaProviderItem fromBundle(final Bundle bundle) { + if (bundle == null) { + return null; + } + final String id = bundle.getString(MediaPluginContract.ITEM_ID); + final String mime = bundle.getString(MediaPluginContract.ITEM_MIME); + if (id == null || mime == null) { + return null; + } + final String preview = bundle.getString(MediaPluginContract.ITEM_PREVIEW_URI); + final String content = bundle.getString(MediaPluginContract.ITEM_CONTENT_URI); + return new MediaProviderItem(id, + bundle.getString(MediaPluginContract.ITEM_TITLE), + mime, + bundle.getString(MediaPluginContract.ITEM_LABEL, MediaPluginContract.DEFAULT_MEDIA_LABEL), + preview == null ? null : Uri.parse(preview), + content == null ? null : Uri.parse(content), + bundle.getLong(MediaPluginContract.ITEM_SIZE_BYTES, -1), + MediaPluginContract.MIME_FOLDER.equals(mime)); + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 4795cda982..230429fa5b 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -75,6 +75,8 @@ object Defaults { const val PREF_SHOW_LANGUAGE_SWITCH_KEY = false const val PREF_LANGUAGE_SWITCH_KEY = "internal" const val PREF_SHOW_EMOJI_KEY = false + const val PREF_ENABLE_MEDIA_PLUGINS = false + const val PREF_MEDIA_PUBLIC_STORAGE_FALLBACK = false const val PREF_VARIABLE_TOOLBAR_DIRECTION = true const val PREF_ADDITIONAL_SUBTYPES = "de${Separators.SET}${ExtraValue.KEYBOARD_LAYOUT_SET}=MAIN:qwerty${Separators.SETS}" + "fr${Separators.SET}${ExtraValue.KEYBOARD_LAYOUT_SET}=MAIN:qwertz${Separators.SETS}" + diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 21e4852585..807730d76d 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -92,6 +92,9 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_SHOW_LANGUAGE_SWITCH_KEY = "show_language_switch_key"; public static final String PREF_LANGUAGE_SWITCH_KEY = "language_switch_key"; public static final String PREF_SHOW_EMOJI_KEY = "show_emoji_key"; + public static final String PREF_ENABLE_MEDIA_PLUGINS = "enable_media_plugins"; + public static final String PREF_MEDIA_PUBLIC_STORAGE_FALLBACK = + "pref_media_public_storage_fallback"; public static final String PREF_VARIABLE_TOOLBAR_DIRECTION = "var_toolbar_direction"; public static final String PREF_ADDITIONAL_SUBTYPES = "additional_subtypes"; public static final String PREF_ENABLE_SPLIT_KEYBOARD = "split_keyboard"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index c44c70d9ba..87efbe89f5 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -75,6 +75,7 @@ public class SettingsValues { public final boolean mShowTldPopupKeys; public final boolean mSpaceForLangChange; public final boolean mShowsEmojiKey; + public final boolean mMediaPluginsEnabled; public final boolean mVarToolbarDirection; public final boolean mUsePersonalizedDicts; public final boolean mUseDoubleSpacePeriod; @@ -206,6 +207,7 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina mShowTldPopupKeys = prefs.getBoolean(Settings.PREF_SHOW_TLD_POPUP_KEYS, Defaults.PREF_SHOW_TLD_POPUP_KEYS); mSpaceForLangChange = prefs.getBoolean(Settings.PREF_SPACE_TO_CHANGE_LANG, Defaults.PREF_SPACE_TO_CHANGE_LANG); mShowsEmojiKey = prefs.getBoolean(Settings.PREF_SHOW_EMOJI_KEY, Defaults.PREF_SHOW_EMOJI_KEY); + mMediaPluginsEnabled = prefs.getBoolean(Settings.PREF_ENABLE_MEDIA_PLUGINS, Defaults.PREF_ENABLE_MEDIA_PLUGINS); mVarToolbarDirection = mToolbarMode != ToolbarMode.HIDDEN && prefs.getBoolean(Settings.PREF_VARIABLE_TOOLBAR_DIRECTION, Defaults.PREF_VARIABLE_TOOLBAR_DIRECTION); mUsePersonalizedDicts = prefs.getBoolean(Settings.PREF_KEY_USE_PERSONALIZED_DICTS, Defaults.PREF_KEY_USE_PERSONALIZED_DICTS); mUseDoubleSpacePeriod = prefs.getBoolean(Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD, Defaults.PREF_KEY_USE_DOUBLE_SPACE_PERIOD) diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index e7c59d1a9c..5cb53a49e7 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -77,6 +77,7 @@ object SettingsWithoutKey { const val GITHUB_WIKI = "github_wiki" const val SAVE_LOG = "save_log" const val BACKUP_RESTORE = "backup_restore" + const val MEDIA_PLUGINS = "media_plugins" const val DEBUG_SETTINGS = "screen_debug" const val LOAD_GESTURE_LIB = "load_gesture_library" const val BACKGROUND_IMAGE = "background_image" diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt index cd4f49407c..b5a6c7e375 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -25,6 +25,7 @@ import helium314.keyboard.settings.screens.DictionaryScreen import helium314.keyboard.settings.screens.GestureTypingScreen import helium314.keyboard.settings.screens.LanguageScreen import helium314.keyboard.settings.screens.MainSettingsScreen +import helium314.keyboard.settings.screens.MediaPluginsScreen import helium314.keyboard.settings.screens.PersonalDictionariesScreen import helium314.keyboard.settings.screens.PersonalDictionaryScreen import helium314.keyboard.settings.screens.PreferencesScreen @@ -89,6 +90,9 @@ fun SettingsNavHost( composable(SettingsDestination.Preferences) { PreferencesScreen(onClickBack = ::goBack) } + composable(SettingsDestination.MediaPlugins) { + MediaPluginsScreen(onClickBack = ::goBack) + } composable(SettingsDestination.Toolbar) { ToolbarScreen(onClickBack = ::goBack) } @@ -149,6 +153,7 @@ object SettingsDestination { const val About = "about" const val TextCorrection = "text_correction" const val Preferences = "preferences" + const val MediaPlugins = "media_plugins" const val Toolbar = "toolbar" const val GestureTyping = "gesture_typing" const val DataGathering = "data_gathering" // remove when data gathering phase is done (end of 2026 latest) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/MediaPluginsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/MediaPluginsScreen.kt new file mode 100644 index 0000000000..33fff64a48 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/MediaPluginsScreen.kt @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.latin.R +import helium314.keyboard.latin.media.provider.MediaPluginApprovalStore +import helium314.keyboard.latin.media.provider.MediaProviderClient +import helium314.keyboard.settings.SearchSettingsScreen +import helium314.keyboard.settings.preferences.Preference +import helium314.keyboard.settings.preferences.PreferenceCategory + +@Composable +fun MediaPluginsScreen( + onClickBack: () -> Unit, +) { + val context = LocalContext.current + var refresh by remember { mutableIntStateOf(0) } + val providers = remember(refresh) { + val client = MediaProviderClient(context) + try { + client.getDiscoveredProviders() + } finally { + client.close() + } + } + + SearchSettingsScreen( + onClickBack = onClickBack, + title = stringResource(R.string.manage_media_plugins), + settings = emptyList(), + content = { + Column { + PreferenceCategory(stringResource(R.string.manage_media_plugins)) + if (providers.isEmpty()) { + Preference( + name = stringResource(R.string.media_plugins_no_plugins), + description = stringResource(R.string.media_plugins_no_plugins_summary), + onClick = {} + ) + } else { + providers.forEach { provider -> + val enabled = MediaPluginApprovalStore.isEnabled(context, provider) + fun setEnabled(value: Boolean) { + MediaPluginApprovalStore.setEnabled(context, provider, value) + KeyboardSwitcher.getInstance().setThemeNeedsReload() + refresh++ + } + Preference( + name = provider.label, + description = provider.packageName + "/" + provider.serviceName, + onClick = { setEnabled(!enabled) } + ) { + Switch( + checked = enabled, + onCheckedChange = { setEnabled(it) }, + ) + } + } + } + } + } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt index 1fb9b623b1..e1903bb88f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt @@ -21,8 +21,12 @@ import helium314.keyboard.latin.utils.SubtypeSettings import helium314.keyboard.latin.utils.getActivity import helium314.keyboard.latin.utils.locale import helium314.keyboard.latin.utils.prefs +import helium314.keyboard.latin.utils.NextScreenIcon import helium314.keyboard.settings.preferences.ListPreference import helium314.keyboard.settings.Setting +import helium314.keyboard.settings.SettingsDestination +import helium314.keyboard.settings.SettingsWithoutKey +import helium314.keyboard.settings.preferences.Preference import helium314.keyboard.settings.preferences.ReorderSwitchPreference import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.SettingsActivity @@ -74,6 +78,9 @@ fun PreferencesScreen( Settings.PREF_SHOW_LANGUAGE_SWITCH_KEY, Settings.PREF_LANGUAGE_SWITCH_KEY, Settings.PREF_SHOW_EMOJI_KEY, + Settings.PREF_ENABLE_MEDIA_PLUGINS, + SettingsWithoutKey.MEDIA_PLUGINS, + Settings.PREF_MEDIA_PUBLIC_STORAGE_FALLBACK, Settings.PREF_REMOVE_REDUNDANT_POPUPS, R.string.settings_category_clipboard_history, Settings.PREF_ENABLE_CLIPBOARD_HISTORY, @@ -156,6 +163,25 @@ fun createPreferencesSettings(context: Context) = listOf( Setting(context, Settings.PREF_SHOW_EMOJI_KEY, R.string.show_emoji_key) { SwitchPreference(it, Defaults.PREF_SHOW_EMOJI_KEY) { KeyboardSwitcher.getInstance().reloadKeyboard() } }, + Setting(context, Settings.PREF_ENABLE_MEDIA_PLUGINS, + R.string.enable_media_plugins, R.string.enable_media_plugins_summary) + { + SwitchPreference(it, Defaults.PREF_ENABLE_MEDIA_PLUGINS) { KeyboardSwitcher.getInstance().setThemeNeedsReload() } + }, + Setting(context, SettingsWithoutKey.MEDIA_PLUGINS, + R.string.manage_media_plugins, R.string.manage_media_plugins_summary) + { + Preference( + name = it.title, + description = it.description, + onClick = { SettingsDestination.navigateTo(SettingsDestination.MediaPlugins) } + ) { NextScreenIcon() } + }, + Setting(context, Settings.PREF_MEDIA_PUBLIC_STORAGE_FALLBACK, + R.string.media_public_storage_fallback, R.string.media_public_storage_fallback_summary) + { + SwitchPreference(it, Defaults.PREF_MEDIA_PUBLIC_STORAGE_FALLBACK) + }, Setting(context, Settings.PREF_REMOVE_REDUNDANT_POPUPS, R.string.remove_redundant_popups, R.string.remove_redundant_popups_summary) { diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000000..7b434b7e55 --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_media_folder.xml b/app/src/main/res/drawable/ic_media_folder.xml new file mode 100644 index 0000000000..f449357892 --- /dev/null +++ b/app/src/main/res/drawable/ic_media_folder.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4aa335f81..1effaff189 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,22 @@ Language switch key behavior Emoji key + + Media plugins + + Show media picker button in the emoji toolbar + + Manage media plugins + + Choose which installed media plugins HeliBoard can use + + No media plugins installed + + Install an app that provides HeliBoard media plugins, then return here to enable it. + + Public media fallback for sharing + + Allows public MediaStore fallback when private media sharing fails. May create temporary public media files. %s ms diff --git a/app/src/main/res/xml/media_file_provider_paths.xml b/app/src/main/res/xml/media_file_provider_paths.xml new file mode 100644 index 0000000000..0b66879c62 --- /dev/null +++ b/app/src/main/res/xml/media_file_provider_paths.xml @@ -0,0 +1,4 @@ + + + +