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