Feat(ToolView): Support upload and download file
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".OxygenApplication"
|
android:name=".OxygenApplication"
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
package top.fatweb.oxygen.toolbox.ui.view
|
package top.fatweb.oxygen.toolbox.ui.view
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.ConsoleMessage
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.ValueCallback
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
@@ -21,7 +30,9 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -37,7 +48,10 @@ import top.fatweb.oxygen.toolbox.R
|
|||||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||||
import top.fatweb.oxygen.toolbox.ui.component.Indicator
|
import top.fatweb.oxygen.toolbox.ui.component.Indicator
|
||||||
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
|
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
|
||||||
|
import top.fatweb.oxygen.toolbox.ui.util.ResourcesUtils
|
||||||
import top.fatweb.oxygen.toolbox.util.NativeWebApi
|
import top.fatweb.oxygen.toolbox.util.NativeWebApi
|
||||||
|
import top.fatweb.oxygen.toolbox.util.Permissions
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ToolViewRoute(
|
internal fun ToolViewRoute(
|
||||||
@@ -67,6 +81,25 @@ internal fun ToolViewScreen(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var fileChooserCallback by remember { mutableStateOf<ValueCallback<Array<Uri>>?>(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(
|
Scaffold(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
@@ -146,15 +179,60 @@ internal fun ToolViewScreen(
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onShowFileChooser(
|
||||||
|
webView: WebView?,
|
||||||
|
filePathCallback: ValueCallback<Array<Uri>>?,
|
||||||
|
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 = {
|
onCreated = {
|
||||||
it.settings.javaScriptEnabled = true
|
it.settings.javaScriptEnabled = true
|
||||||
it.settings.domStorageEnabled = true
|
it.settings.domStorageEnabled = true
|
||||||
it.addJavascriptInterface(
|
it.addJavascriptInterface(
|
||||||
NativeWebApi(context = context, webView = it),
|
NativeWebApi(context = context, webView = it, permissionLauncher),
|
||||||
"NativeApi"
|
"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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ package top.fatweb.oxygen.toolbox.util
|
|||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.CLIPBOARD_SERVICE
|
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.JavascriptInterface
|
||||||
import android.webkit.WebView
|
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(
|
class NativeWebApi(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val webView: WebView
|
private val webView: WebView,
|
||||||
|
private val permissionLauncher: ManagedActivityResultLauncher<String, Boolean>
|
||||||
) {
|
) {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun copyToClipboard(text: String): Boolean {
|
fun copyToClipboard(text: String): Boolean {
|
||||||
@@ -26,6 +38,65 @@ class NativeWebApi(
|
|||||||
return clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
|
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) {
|
private fun callback(callback: String, vararg args: Any) {
|
||||||
val jsCode =
|
val jsCode =
|
||||||
"$callback(${args.map { if (it is String) "'$it'" else it }.joinToString(", ")})"
|
"$callback(${args.map { if (it is String) "'$it'" else it }.joinToString(", ")})"
|
||||||
|
|||||||
@@ -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<Boolean>? = null
|
||||||
|
|
||||||
|
suspend fun requestWriteExternalStoragePermission(
|
||||||
|
context: Context,
|
||||||
|
permissionLauncher: ManagedActivityResultLauncher<String, Boolean>
|
||||||
|
): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@
|
|||||||
<string name="core_undo">撤消</string>
|
<string name="core_undo">撤消</string>
|
||||||
<string name="core_nothing">空空如也</string>
|
<string name="core_nothing">空空如也</string>
|
||||||
<string name="core_nothing_found">未找到相关内容</string>
|
<string name="core_nothing_found">未找到相关内容</string>
|
||||||
|
<string name="core_file_select_one">选择一个文件</string>
|
||||||
|
<string name="core_file_select_multi">选择文件</string>
|
||||||
|
<string name="core_file_select_one_text">选择一个文本文件</string>
|
||||||
|
<string name="core_file_select_multi_text">选择文本文件</string>
|
||||||
|
<string name="core_can_only_download_http_https">只能下载 HTTP/HTTPS URI:%1$s</string>
|
||||||
|
|
||||||
<string name="feature_store_title">商店</string>
|
<string name="feature_store_title">商店</string>
|
||||||
<string name="feature_store_retry">重试</string>
|
<string name="feature_store_retry">重试</string>
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
<string name="core_undo">Undo</string>
|
<string name="core_undo">Undo</string>
|
||||||
<string name="core_nothing">Nothing</string>
|
<string name="core_nothing">Nothing</string>
|
||||||
<string name="core_nothing_found">Nothing found</string>
|
<string name="core_nothing_found">Nothing found</string>
|
||||||
|
<string name="core_file_select_one">Select a file</string>
|
||||||
|
<string name="core_file_select_multi">Select files</string>
|
||||||
|
<string name="core_file_select_one_text">Select a text file</string>
|
||||||
|
<string name="core_file_select_multi_text">Select text files</string>
|
||||||
|
<string name="core_can_only_download_http_https">Can only download HTTP/HTTPS URIs: %1$s</string>
|
||||||
|
|
||||||
<string name="feature_store_title">Store</string>
|
<string name="feature_store_title">Store</string>
|
||||||
<string name="feature_store_retry">try again</string>
|
<string name="feature_store_retry">try again</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user