From dffd8eb5a87558afc2fca21870643ff7dad9eb04 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Fri, 17 Feb 2023 07:44:28 -0800 Subject: [PATCH] feat(android): add Plugin assetUrl function (#6299) --- .../src/main/java/app/tauri/FsUtils.kt | 197 ++++++++++++++++++ .../src/main/java/app/tauri/plugin/Plugin.kt | 13 ++ 2 files changed, 210 insertions(+) create mode 100644 core/tauri/mobile/android/src/main/java/app/tauri/FsUtils.kt diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/FsUtils.kt b/core/tauri/mobile/android/src/main/java/app/tauri/FsUtils.kt new file mode 100644 index 000000000..cf6735226 --- /dev/null +++ b/core/tauri/mobile/android/src/main/java/app/tauri/FsUtils.kt @@ -0,0 +1,197 @@ +package app.tauri + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import java.io.File +import java.io.FileOutputStream +import kotlin.math.min + +internal class FsUtils { + companion object { + fun getFileUrlForUri(context: Context, uri: Uri): String? { + // DocumentProvider + if (DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId: String = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + return legacyPrimaryPath(split[1]) + } else { + val splitIndex = docId.indexOf(':', 1) + val tag = docId.substring(0, splitIndex) + val path = docId.substring(splitIndex + 1) + val nonPrimaryVolume = getPathToNonPrimaryVolume(context, tag) + if (nonPrimaryVolume != null) { + val result = "$nonPrimaryVolume/$path" + val file = File(result) + return if (file.exists() && file.canRead()) { + result + } else null + } + } + } else if (isDownloadsDocument(uri)) { + val id: String = DocumentsContract.getDocumentId(uri) + val contentUri: Uri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), + java.lang.Long.valueOf(id) + ) + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId: String = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + val type = split[0] + var contentUri: Uri? = null + when (type) { + "image" -> { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + "video" -> { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + "audio" -> { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + } + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + if (contentUri != null) { + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } + } else if ("content".equals(uri.scheme, ignoreCase = true)) { + // Return the remote address + return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn( + context, + uri, + null, + null + ) + } else if ("file".equals(uri.scheme, ignoreCase = true)) { + return uri.path + } + return null + } + + /** + * Get the value of the data column for this Uri. This is useful for + * MediaStore Uris, and other file-based ContentProviders. + * + * @param context The context. + * @param uri The Uri to query. + * @param selection (Optional) Filter used in the query. + * @param selectionArgs (Optional) Selection arguments used in the query. + * @return The value of the _data column, which is typically a file path. + */ + private fun getDataColumn( + context: Context, + uri: Uri, + selection: String?, + selectionArgs: Array? + ): String? { + var path: String? = null + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf(column) + try { + cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndexOrThrow(column) + path = cursor.getString(index) + } + } catch (ex: IllegalArgumentException) { + return getCopyFilePath(uri, context) + } finally { + cursor?.close() + } + return path ?: getCopyFilePath(uri, context) + } + + private fun getCopyFilePath(uri: Uri, context: Context): String? { + val cursor = context.contentResolver.query(uri, null, null, null, null)!! + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + val name = cursor.getString(nameIndex) + val file = File(context.filesDir, name) + try { + val inputStream = context.contentResolver.openInputStream(uri) + val outputStream = FileOutputStream(file) + var read: Int + val maxBufferSize = 1024 * 1024 + val bufferSize = min(inputStream!!.available(), maxBufferSize) + val buffers = ByteArray(bufferSize) + while (inputStream.read(buffers).also { read = it } != -1) { + outputStream.write(buffers, 0, read) + } + inputStream.close() + outputStream.close() + } catch (e: Exception) { + return null + } finally { + cursor.close() + } + return file.path + } + + private fun legacyPrimaryPath(pathPart: String): String { + return Environment.getExternalStorageDirectory().toString() + "/" + pathPart + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + private fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.authority + } + + private fun getPathToNonPrimaryVolume(context: Context, tag: String): String? { + val volumes = context.externalCacheDirs + if (volumes != null) { + for (volume in volumes) { + if (volume != null) { + val path = volume.absolutePath + val index = path.indexOf(tag) + if (index != -1) { + return path.substring(0, index) + tag + } + } + } + } + return null + } + } +} diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt index 6d33cadde..1f4528130 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt @@ -3,8 +3,10 @@ package app.tauri.plugin import android.app.Activity import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.webkit.WebView import androidx.core.app.ActivityCompat +import app.tauri.FsUtils import app.tauri.Logger import app.tauri.PermissionHelper import app.tauri.PermissionState @@ -50,6 +52,17 @@ abstract class Plugin(private val activity: Activity) { return Logger.tags(this.javaClass.simpleName) } + /** + * Convert an URI to an URL that can be loaded by the webview. + */ + fun assetUrl(u: Uri): String { + var path = FsUtils.getFileUrlForUri(activity, u) + if (path?.startsWith("file://") == true) { + path = path.replace("file://", "") + } + return "asset://localhost$path" + } + /** * Exported plugin method for checking the granted status for each permission * declared on the plugin. This plugin call responds with a mapping of permissions to