diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2f8140..2060454 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + >?>(null) } + + val fileChooserLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> + fileChooserCallback?.onReceiveValue(arrayOf(uri)) + } ?: { + fileChooserCallback?.onReceiveValue(emptyArray()) + } + } else { + fileChooserCallback?.onReceiveValue(emptyArray()) + } + } + + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + Permissions.continuation?.resume(it) + } + Scaffold( modifier = Modifier, containerColor = Color.Transparent, @@ -146,15 +179,60 @@ internal fun ToolViewScreen( } return false } + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams? + ): Boolean { + fileChooserCallback = filePathCallback + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + fileChooserLauncher.launch( + Intent.createChooser( + intent, + ResourcesUtils.getString( + context = context, + resId = R.string.core_file_select_one_text + ) + ) + ) + + return true + } } }, onCreated = { it.settings.javaScriptEnabled = true it.settings.domStorageEnabled = true it.addJavascriptInterface( - NativeWebApi(context = context, webView = it), + NativeWebApi(context = context, webView = it, permissionLauncher), "NativeApi" ) + it.setDownloadListener { url, userAgent, _, mimetype, _ -> + if (!listOf("http://", "https://").any(url::startsWith)) { + it.evaluateJavascript( + "alert('${ + ResourcesUtils.getString( + context = context, + resId = R.string.core_can_only_download_http_https, + url + ) + }')" + ) {} + return@setDownloadListener + } + val request = DownloadManager.Request(Uri.parse(url)).apply { + addRequestHeader("User-Agent", userAgent) + setMimeType(mimetype) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + } + val downloadManager: DownloadManager = + context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + downloadManager.enqueue(request) + } } ) } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/NativeWebApi.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/NativeWebApi.kt index a92e163..4e3c94e 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/NativeWebApi.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/NativeWebApi.kt @@ -2,14 +2,26 @@ package top.fatweb.oxygen.toolbox.util import android.content.ClipData import android.content.ClipboardManager +import android.content.ContentValues import android.content.Context import android.content.Context.CLIPBOARD_SERVICE +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import android.webkit.JavascriptInterface import android.webkit.WebView +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.annotation.RequiresApi +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException class NativeWebApi( private val context: Context, - private val webView: WebView + private val webView: WebView, + private val permissionLauncher: ManagedActivityResultLauncher ) { @JavascriptInterface fun copyToClipboard(text: String): Boolean { @@ -26,6 +38,65 @@ class NativeWebApi( return clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() ?: "" } + @JavascriptInterface + fun saveToDownloads(data: ByteArray, fileName: String): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveFileToDownloads(data = data, fileName = fileName) + } else { + saveFileToExternalDownloads(data = data, fileName = fileName) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveFileToDownloads(data: ByteArray, fileName: String): Boolean { + val resolver = context.contentResolver + + val contentValues = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream") + put(MediaStore.Downloads.IS_PENDING, 1) + } + + val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val fileUri = resolver.insert(collection, contentValues) + + return fileUri?.let { uri -> + resolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(data) + outputStream.flush() + } + + contentValues.clear() + contentValues.put(MediaStore.Downloads.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + true + } ?: let { + Timber.e("Could not save file $fileName to Downloads") + false + } + } + + private fun saveFileToExternalDownloads(data: ByteArray, fileName: String): Boolean { + if (!runBlocking { Permissions.requestWriteExternalStoragePermission(context, permissionLauncher) }) { + return false + } + + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val file = File(downloadsDir, fileName) + + return try { + FileOutputStream(file).apply { + write(data) + close() + } + true + } catch (e: IOException) { + Timber.e("Could not save file $fileName to ${file.absolutePath}", e) + false + } + } + private fun callback(callback: String, vararg args: Any) { val jsCode = "$callback(${args.map { if (it is String) "'$it'" else it }.joinToString(", ")})" diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Permissions.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Permissions.kt new file mode 100644 index 0000000..52175d3 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Permissions.kt @@ -0,0 +1,41 @@ +package top.fatweb.oxygen.toolbox.util + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.core.content.ContextCompat +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +object Permissions { + var continuation: Continuation? = null + + suspend fun requestWriteExternalStoragePermission( + context: Context, + permissionLauncher: ManagedActivityResultLauncher + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return true + } + + return suspendCancellableCoroutine { continuation -> + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + ) { + continuation.resume(true) + } else { + this.continuation = continuation + permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + continuation.invokeOnCancellation { + continuation.resume(false) + } + } + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 30a7898..dca7b9b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -26,6 +26,11 @@ 撤消 空空如也 未找到相关内容 + 选择一个文件 + 选择文件 + 选择一个文本文件 + 选择文本文件 + 只能下载 HTTP/HTTPS URI:%1$s 商店 重试 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70229d8..39477a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,11 @@ Undo Nothing Nothing found + Select a file + Select files + Select a text file + Select text files + Can only download HTTP/HTTPS URIs: %1$s Store try again