Add image download functionality from WebView

This commit is contained in:
Alex Abudaev 2026-05-09 15:30:00 +08:00
parent 1b409ea9a7
commit 6b940730f4
2 changed files with 166 additions and 1 deletions

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application
android:allowBackup="true"

View file

@ -13,7 +13,12 @@ import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.DownloadListener
import android.widget.Toast
import android.os.Environment
import android.app.DownloadManager
import android.provider.Settings
import java.io.File as IoFile
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
@ -73,7 +78,65 @@ class MainActivity : AppCompatActivity() {
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun downloadImage(url: String) {
runOnUiThread {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return@runOnUiThread
}
val fileName = android.webkit.URLUtil.guessFileName(url, null, null) ?: "image_${System.currentTimeMillis()}.png"
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val downloadRequest = DownloadManager.Request(Uri.parse(url)).apply {
setMimeType("image/png")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
}
downloadManager.enqueue(downloadRequest)
Toast.makeText(this@MainActivity, "Downloading...", Toast.LENGTH_SHORT).show()
}
}
@android.webkit.JavascriptInterface
fun downloadBase64Image(base64Data: String) {
runOnUiThread {
try {
val base64 = base64Data.substringAfter("base64,")
val imageBytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val fileName = "duckai_image_${System.currentTimeMillis()}.jpg"
val contentValues = android.content.ContentValues().apply {
put(android.provider.MediaStore.Downloads.DISPLAY_NAME, fileName)
put(android.provider.MediaStore.Downloads.MIME_TYPE, "image/jpeg")
put(android.provider.MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = contentResolver
val uri = resolver.insert(android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(imageBytes)
}
contentValues.clear()
contentValues.put(android.provider.MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
Toast.makeText(this@MainActivity, "Файл сохранён в папке «Загрузки» (Downloads)\n$fileName", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(this@MainActivity, "Download failed", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@MainActivity, "Ошибка сохранения: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}, "Android")
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(message: String?, lineNumber: Int, sourceID: String?) {
message?.let { android.util.Log.d("DuckAI", it) }
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
@ -88,6 +151,26 @@ class MainActivity : AppCompatActivity() {
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.postDelayed({ tryFocusInput(view) }, 2000)
view?.postDelayed({ injectDownloadHandler(view) }, 1500)
}
}
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
if (url.startsWith("blob:") || url.startsWith("data:")) {
return@setDownloadListener
} else if (!url.startsWith("http://") && !url.startsWith("https://")) {
return@setDownloadListener
} else {
val fileName = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType) ?: "download"
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val downloadRequest = DownloadManager.Request(Uri.parse(url)).apply {
setMimeType(mimeType)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
}
downloadManager.enqueue(downloadRequest)
Toast.makeText(this, "Downloading...", Toast.LENGTH_SHORT).show()
}
}
}
@ -133,13 +216,84 @@ class MainActivity : AppCompatActivity() {
" return 'NOT FOUND';" +
"})();"
) { result ->
android.util.Log.d("DuckAI", "Focus result: " + result)
if (result?.contains("NOT FOUND") == true) {
view?.postDelayed({ tryFocusInput(view) }, 1000)
}
}
}
private fun injectDownloadHandler(view: WebView?) {
view?.evaluateJavascript(
"(function() {" +
" if (window.downloadHandlerInjected) return;" +
" window.downloadHandlerInjected = true;" +
" " +
" function downloadBlobImage(src) {" +
" fetch(src)" +
" .then(res => res.blob())" +
" .then(blob => {" +
" const reader = new FileReader();" +
" reader.onloadend = function() {" +
" const base64 = reader.result.split(',')[1];" +
" const link = document.createElement('a');" +
" link.href = 'data:image/png;base64,' + base64;" +
" link.download = 'duckai_image_' + Date.now() + '.png';" +
" link.click();" +
" };" +
" reader.readAsDataURL(blob);" +
" })" +
" }" +
" " +
" function findImageSrc(btn) {" +
" let el = btn;" +
" for (let i = 0; i < 5 && el; i++) {" +
" const img = el.querySelector('img');" +
" if (img && img.src) return img.src;" +
" const sources = el.querySelectorAll('source');" +
" for (let s of sources) {" +
" if (s.src && s.src.startsWith('blob:')) return s.src;" +
" }" +
" el = el.parentElement;" +
" }" +
" const allImgs = document.querySelectorAll('img');" +
" for (let img of allImgs) {" +
" if (img.src.startsWith('blob:')) return img.src;" +
" }" +
" return null;" +
" }" +
" " +
" document.body.addEventListener('click', function(e) {" +
" const target = e.target;" +
" const btn = target.closest('button');" +
" if (!btn) return;" +
" " +
" const svg = btn.querySelector('svg');" +
" if (!svg) return;" +
" " +
" const path = svg.querySelector('path');" +
" if (!path || !path.getAttribute('d')) return;" +
" " +
" const d = path.getAttribute('d');" +
" " +
" if (d.includes('M8 .5') || d.includes('8.264')) {" +
" e.preventDefault();" +
" e.stopPropagation();" +
" " +
" const imgSrc = findImageSrc(btn);" +
" if (!imgSrc) return;" +
" " +
" if (imgSrc.startsWith('data:')) {" +
" window.Android && window.Android.downloadBase64Image && window.Android.downloadBase64Image(imgSrc);" +
" } else if (imgSrc.startsWith('blob:')) {" +
" downloadBlobImage(imgSrc);" +
" } else if (imgSrc.startsWith('http')) {" +
" window.Android && window.Android.downloadImage && window.Android.downloadImage(imgSrc);" +
" }" +
" }" +
" }, true);" +
"})();" , null)
}
private fun checkPermissionAndOpenPicker() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
openFilePicker()
@ -197,6 +351,9 @@ class MainActivity : AppCompatActivity() {
window.decorView.postDelayed({
requestFocusAndShowKeyboard()
}, 1200)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
private fun requestFocusAndShowKeyboard() {
@ -233,6 +390,9 @@ override fun onResume() {
tryFocusInput(webView)
showKeyboard()
}, 1500)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
override fun onNewIntent(intent: Intent?) {
@ -246,6 +406,9 @@ override fun onResume() {
webView.postDelayed({
requestFocusAndShowKeyboard()
}, 1200)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
}