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 @@
+
+
+
+