mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 06:24:35 +03:00
feat: implement media handling via native bridge for Android
This commit is contained in:
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user