feat: implement media handling via native bridge for Android

This commit is contained in:
Sarto
2026-05-01 00:01:51 +03:30
parent fea393a627
commit 76d958bdff
4 changed files with 156 additions and 12 deletions
+10
View File
@@ -34,6 +34,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>
@@ -1,8 +1,18 @@
package com.thefeed.android
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
import android.webkit.JavascriptInterface
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
class AndroidBridge(private val activity: Activity) {
@@ -54,6 +64,83 @@ class AndroidBridge(private val activity: Activity) {
return sha256(password) == stored
}
// ===== Media handoff to system apps =====
// The web frontend calls these for save / open / share when it
// detects window.Android — WebView can't natively download blob URLs,
// navigate to blob URLs in new tabs, or do navigator.share with files.
@JavascriptInterface
fun openMedia(base64: String, mime: String, filename: String): Boolean {
return try {
val uri = writeToCache(base64, filename)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, sanitiseMime(mime))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(Intent.createChooser(intent, filename))
true
} catch (_: Exception) { false }
}
@JavascriptInterface
fun shareMedia(base64: String, mime: String, filename: String): Boolean {
return try {
val uri = writeToCache(base64, filename)
val intent = Intent(Intent.ACTION_SEND).apply {
type = sanitiseMime(mime)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(Intent.createChooser(intent, filename))
true
} catch (_: Exception) { false }
}
@JavascriptInterface
fun saveMedia(base64: String, mime: String, filename: String): Boolean {
return try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val safe = sanitiseFilename(filename)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = activity.contentResolver
val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, safe)
put(MediaStore.MediaColumns.MIME_TYPE, sanitiseMime(mime))
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val target = resolver.insert(collection, values) ?: return false
resolver.openOutputStream(target)?.use { it.write(bytes) }
true
} else {
@Suppress("DEPRECATION")
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!dir.exists()) dir.mkdirs()
val out = File(dir, safe)
FileOutputStream(out).use { it.write(bytes) }
true
}
} catch (_: Exception) { false }
}
private fun writeToCache(base64: String, filename: String): Uri {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val dir = File(activity.cacheDir, "shared")
dir.mkdirs()
val out = File(dir, sanitiseFilename(filename))
FileOutputStream(out).use { it.write(bytes) }
return FileProvider.getUriForFile(activity, activity.packageName + ".fileprovider", out)
}
private fun sanitiseFilename(name: String): String {
val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_").trim('_', '.')
return if (cleaned.isEmpty()) "media" else cleaned
}
private fun sanitiseMime(m: String): String {
return if (m.matches(Regex("^[\\w./+-]+$"))) m else "application/octet-stream"
}
private fun sha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray(Charsets.UTF_8))
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="shared" path="shared/" />
</paths>
+55 -12
View File
@@ -4795,9 +4795,29 @@
+ '<div class="media-file-actions" id="' + domID + '-actions"></div>';
}
// Read window.Android once. The native bridge is the WebView wrapper's
// way of getting around blob-URL / Web-Share limitations on Android.
var androidBridge = (typeof window !== 'undefined' && window.Android) ? window.Android : null;
function blobToBase64(blob) {
return new Promise(function (resolve, reject) {
var fr = new FileReader();
fr.onload = function () {
var result = fr.result || '';
var idx = result.indexOf(',');
resolve(idx >= 0 ? result.substring(idx + 1) : result);
};
fr.onerror = function () { reject(fr.error); };
fr.readAsDataURL(blob);
});
}
function buildMediaActions(msgID, tag) {
var isImage = mediaIsImageTag(tag);
var canShare = !!(navigator.share && navigator.canShare);
// On Android the native bridge always handles share, so we always
// show the share button there — unlike browser path which needs
// navigator.canShare support.
var canShare = !!androidBridge || !!(navigator.share && navigator.canShare);
var openTitle = esc(t('media_open') || 'Open');
var saveTitle = esc(t('media_save') || 'Save');
var shareTitle = esc(t('media_share') || 'Share');
@@ -4812,7 +4832,7 @@
return html;
}
function mediaOpen(msgID) {
async function mediaOpen(msgID) {
var entry = mediaBlobs[msgID];
if (!entry) return;
var card = document.getElementById('media-' + msgID);
@@ -4821,6 +4841,14 @@
showImageLightbox(entry.url, mediaTagLabel(tag));
return;
}
if (androidBridge && androidBridge.openMedia) {
var fname = mediaFilenameFor(msgID, tag, entry.mime);
try {
var b64 = await blobToBase64(entry.blob);
androidBridge.openMedia(b64, entry.mime || 'application/octet-stream', fname);
} catch (e) { }
return;
}
try {
var a = document.createElement('a');
a.href = entry.url;
@@ -4833,6 +4861,16 @@
} catch (e) { }
}
function mediaFilenameFor(msgID, tag, mime) {
var card = document.getElementById('media-' + msgID);
var fname = card ? (card.getAttribute('data-fname') || '') : '';
if (!fname) {
var ext = mediaExtForTag(tag, mime);
fname = (mediaTagLabel(tag || 'FILE') + '-' + msgID + '.' + ext).toLowerCase();
}
return fname;
}
function showImageLightbox(blobURL, alt) {
var existing = document.getElementById('mediaLightbox');
if (existing) existing.remove();
@@ -4857,16 +4895,18 @@
document.body.appendChild(overlay);
}
function mediaSave(msgID) {
async function mediaSave(msgID) {
var entry = mediaBlobs[msgID];
if (!entry) return;
var card = document.getElementById('media-' + msgID);
var tag = card ? card.getAttribute('data-tag') : '';
// Use the wire-format filename when present, otherwise synthesise.
var fname = card ? (card.getAttribute('data-fname') || '') : '';
if (!fname) {
var ext = mediaExtForTag(tag, entry.mime);
fname = (mediaTagLabel(tag || 'FILE') + '-' + msgID + '.' + ext).toLowerCase();
var fname = mediaFilenameFor(msgID, tag, entry.mime);
if (androidBridge && androidBridge.saveMedia) {
try {
var b64 = await blobToBase64(entry.blob);
androidBridge.saveMedia(b64, entry.mime || 'application/octet-stream', fname);
} catch (e) { }
return;
}
var a = document.createElement('a');
a.href = entry.url;
@@ -4882,10 +4922,13 @@
if (!entry) return;
var card = document.getElementById('media-' + msgID);
var tag = card ? card.getAttribute('data-tag') : '';
var fname = card ? (card.getAttribute('data-fname') || '') : '';
if (!fname) {
var ext = mediaExtForTag(tag, entry.mime);
fname = (mediaTagLabel(tag || 'FILE') + '-' + msgID + '.' + ext).toLowerCase();
var fname = mediaFilenameFor(msgID, tag, entry.mime);
if (androidBridge && androidBridge.shareMedia) {
try {
var b64 = await blobToBase64(entry.blob);
androidBridge.shareMedia(b64, entry.mime || 'application/octet-stream', fname);
} catch (e) { }
return;
}
var file;
try {