兼容 Android Q+ 实现 WebView 图片长按保存与复制

在现代 Android 应用中,许多功能依赖于内嵌的 WebView。其中一个常见需求是:用户长按 WebView 中的图片时,能够将图片保存到系统相册或复制到剪切板。

这个需求在 Android 10 (Q) 引入 Scoped Storage (分区存储) 后变得复杂。本文将提供一个完整的 Kotlin 解决方案,通过扩展方法和统一的存储管理,完美兼容所有 Android 版本。

一、捕获长按事件:WebView 扩展方法

首先,我们需要一个优雅的方式来捕获长按事件,并筛选出只有图片命中的情况。我们采用 Kotlin 扩展方法 onImageLongClick 来封装 HitTestResult 的检查逻辑。

1. 扩展方法 onImageLongClick

我们将图片源(URL 或 Base64 Data URL)作为回调参数返回,简化 Activity/Fragment 的逻辑。

kotlin 复制代码
// WebViewExtensions.kt

import android.webkit.WebView
import android.webkit.WebView.HitTestResult

/**
 * 为 WebView 添加图片长按事件处理器。
 * 只有当长按命中的是图片或图片链接时,才执行回调。
 */
fun WebView.onImageLongClick(callback: (imageUrl: String) -> Unit) {
    this.setOnLongClickListener { v ->
        val hitTestResult = this.hitTestResult
        
        // 检查是否命中图片或图片链接
        if (hitTestResult.type == HitTestResult.IMAGE_TYPE ||
            hitTestResult.type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE
        ) {
            val sourceUrl = hitTestResult.extra
            
            if (sourceUrl != null && sourceUrl.isNotBlank()) {
                callback.invoke(sourceUrl)
                return@setOnLongClickListener true // 消耗事件
            }
        }
        
        return@setOnLongClickListener false // 未命中图片,不消费事件
    }
}

二、统一存储管理:FileDownloadManager

kotlin 复制代码
package com.yourpackage.utils

import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
import android.webkit.MimeTypeMap
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException

object FileDownloadManager {

    private val client = OkHttpClient()

    // --- 公共入口方法 ---

    /**
     * (公共) 将 Base64 Data URL 保存到公共 MediaStore
     */
    suspend fun saveBase64DataUrl(context: Context, dataUrl: String): Uri? {
        return withContext(Dispatchers.IO) {
            try {
                val (mimeType, base64Data) = parseDataUrl(context, dataUrl) ?: return@withContext null
                val extension = getExtensionFromMimeType(mimeType)
                val fileName = "image_${System.currentTimeMillis()}.$extension"

                val imageBytes = Base64.decode(base64Data, Base64.DEFAULT)

                return@withContext saveBytesToMediaStore(context, imageBytes, mimeType, fileName)
            } catch (e: Exception) {
                e.printStackTrace()
                showToast(context, "Base64 保存失败: ${e.message}")
                return@withContext null
            }
        }
    }

    /**
     * (公共) 将网络 URL 文件下载并保存到公共 MediaStore
     */
    suspend fun downloadAndSaveFile(context: Context, fileUrl: String): Uri? {
        return withContext(Dispatchers.IO) {
            try {
                val request = Request.Builder().url(fileUrl).build()
                val response = client.newCall(request).execute()
                if (!response.isSuccessful) {
                    showToast(context, "下载失败: ${response.code}")
                    return@withContext null
                }
                
                val contentType = response.header("Content-Type", "application/octet-stream")
                val mimeType = contentType ?: "application/octet-stream"
                val extension = getExtensionFromMimeType(mimeType)
                val fileName = "download_${System.currentTimeMillis()}.$extension"
                val imageBytes = response.body?.bytes()
                
                if (imageBytes != null) {
                    return@withContext saveBytesToMediaStore(context, imageBytes, mimeType, fileName)
                } else {
                    showToast(context, "下载内容为空")
                    return@withContext null
                }
            } catch (e: IOException) {
                e.printStackTrace()
                showToast(context, "下载异常: ${e.message}")
                return@withContext null
            }
        }
    }

    /**
     * (公共) 将 Content URI 复制到系统剪切板
     */
    fun copyImageToClipboard(context: Context, imageUri: Uri) {
        val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
        val clip = ClipData.newUri(context.contentResolver, "Image", imageUri)
        clipboard.setPrimaryClip(clip)
        showToast(context, "图片已复制到剪切板")
    }

    // --- 内部核心逻辑 ---

    /**
     * (私有-核心) 保存字节到公共 MediaStore (兼容 P- 和 Q+)
     */
    private suspend fun saveBytesToMediaStore(
        context: Context,
        imageBytes: ByteArray,
        mimeType: String,
        fileName: String
    ): Uri? {
        return withContext(Dispatchers.IO) {
            val resolver = context.contentResolver
            val values = ContentValues()
            var fileUri: Uri? = null

            try {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    // --- Q+ 逻辑:使用 Scoped Storage ---
                    val collectionUri: Uri
                    if (mimeType.startsWith("image/")) {
                        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
                        collectionUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
                    } else {
                        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
                        collectionUri = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
                    }
                    values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
                    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
                    values.put(MediaStore.MediaColumns.IS_PENDING, 1)

                    fileUri = resolver.insert(collectionUri, values)
                    fileUri?.let { uri ->
                        resolver.openOutputStream(uri).use { it?.write(imageBytes) }
                        values.clear()
                        values.put(MediaStore.MediaColumns.IS_PENDING, 0)
                        resolver.update(uri, values, null, null)
                    }
                } else {
                    // --- P- 逻辑:使用 Legacy Storage 和 _DATA 字段 ---
                    val publicDir: File
                    val collectionUri: Uri
                    if (mimeType.startsWith("image/")) {
                        publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
                        collectionUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
                    } else {
                        publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
                        collectionUri = MediaStore.Files.getContentUri("external")
                    }
                    if (!publicDir.exists()) publicDir.mkdirs()

                    val file = File(publicDir, fileName)
                    values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
                    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
                    values.put(MediaStore.MediaColumns.DATA, file.absolutePath)

                    fileUri = resolver.insert(collectionUri, values)
                    fileUri?.let { uri ->
                        resolver.openOutputStream(uri).use { it?.write(imageBytes) }
                    }
                }

                if (fileUri != null) {
                    showToast(context, "图片已保存到相册")
                } else {
                    showToast(context, "保存失败")
                }
                return@withContext fileUri

            } catch (e: Exception) {
                e.printStackTrace()
                if (fileUri != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    resolver.delete(fileUri, null, null)
                }
                showToast(context, "保存异常: ${e.message}")
                return@withContext null
            }
        }
    }

    // --- 辅助方法 ---

    private suspend fun parseDataUrl(context: Context, dataUrl: String): Pair<String, String>? {
        if (!dataUrl.startsWith("data:")) {
            showToast(context, "非 Data URL 格式")
            return null
        }
        val commaIndex = dataUrl.indexOf(',')
        if (commaIndex == -1) {
            showToast(context, "Data URL 格式错误")
            return null
        }
        val metadataPart = dataUrl.substring(5, commaIndex)
        val base64Data = dataUrl.substring(commaIndex + 1)
        val mimeType = metadataPart.split(';').firstOrNull() ?: "application/octet-stream"
        return Pair(mimeType, base64Data)
    }
    
    private fun getExtensionFromMimeType(mimeType: String): String {
        return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?:
            when (mimeType) {
                "image/jpeg" -> "jpg"
                "image/png" -> "png"
                "image/webp" -> "webp"
                "image/gif" -> "gif"
                else -> "bin"
            }
    }

    private fun showToast(context: Context, message: String) {
        Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
    }
}

三、集成与权限

这是将所有组件集成在一起的 Activity 代码,它处理了长按监听、流程分发和运行时权限请求。

Kotlin

kotlin 复制代码
package com.yourpackage

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.yourpackage.utils.FileDownloadManager
import com.yourpackage.utils.onImageLongClick // 导入扩展方法
import kotlinx.coroutines.launch

class WebViewActivity : AppCompatActivity() {
    
    private lateinit var webView: WebView
    private var pendingSaveUrl: String? = null // 用于权限回调后存储 URL
    private val REQUEST_WRITE_STORAGE = 1001

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 假设您的布局包含一个 id 为 webview 的 WebView 组件
        webView = WebView(this)
        setContentView(webView)
        
        setupWebViewLongClick()
        webView.loadUrl("https://example.com/page_with_images") // 加载测试页面
    }

    private fun setupWebViewLongClick() {
        // 使用扩展方法设置长按监听
        webView.onImageLongClick { imageUrl ->
            // 提示用户选择操作 (这里简化为直接走保存流程)
            Toast.makeText(this, "发现图片,准备保存...", Toast.LENGTH_SHORT).show()
            checkStoragePermissionAndSave(imageUrl) 
        }
    }

    // --- 权限检查与流程启动 ---

    private fun checkStoragePermissionAndSave(sourceUrl: String) {
        // API 29+ (Android 10+) 不需要权限
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startSaveProcess(sourceUrl)
            return
        }
        
        // API 28- (Android 9-) 需要检查权限
        if (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE
            ) == PackageManager.PERMISSION_GRANTED
        ) {
            startSaveProcess(sourceUrl)
        } else {
            pendingSaveUrl = sourceUrl
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
                REQUEST_WRITE_STORAGE
            )
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_WRITE_STORAGE) {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                pendingSaveUrl?.let {
                    startSaveProcess(it)
                    pendingSaveUrl = null
                }
            } else {
                Toast.makeText(this, "保存文件需要存储权限", Toast.LENGTH_SHORT).show()
                pendingSaveUrl = null
            }
        }
    }

    // --- 核心保存流程 ---

    private fun startSaveProcess(imageUrl: String) {
        lifecycleScope.launch {
            val savedUri = if (imageUrl.startsWith("data:")) {
                // Base64 链接
                FileDownloadManager.saveBase64DataUrl(this@MyWebViewActivity, imageUrl)
            } else {
                // 网络链接
                FileDownloadManager.downloadAndSaveFile(this@MyWebViewActivity, imageUrl)
            }

            if (savedUri != null) {
                // 保存成功后,复制图片到剪切板
                FileDownloadManager.copyImageToClipboard(this@MyWebViewActivity, savedUri)
            }
        }
    }
}
相关推荐
2501_915918412 小时前
HTTP和HTTPS工作原理、安全漏洞及防护措施全面解析
android·http·ios·小程序·https·uni-app·iphone
Little丶Seven3 小时前
使用adb获取安卓模拟器日志
android·unity·adb·个人开发
凉栀お_3 小时前
MySQL第五次作业(触发器,存储过程)
android·mysql·adb
limingade3 小时前
ADB点击实战-做一个自动点广告播放领金币的脚本app(中)
android·adb·智能手机·ocr识别手机广告·ocr识别手机屏幕·adb自动关闭广告
珹洺3 小时前
Java-Spring入门指南(二十九)Android交互核心:按钮点击事件与Activity跳转实战
android·java·spring
2501_916007474 小时前
如何在 Windows 电脑上调试 iOS 设备上的 Safari?完整方案与实战经验分享
android·windows·ios·小程序·uni-app·iphone·safari
2501_915918414 小时前
uni-app iOS日志管理实战,从调试控制台到系统日志的全链路采集与分析指南
android·ios·小程序·https·uni-app·iphone·webview
1024小神4 小时前
Kotlin实现全屏显示效果,挖空和刘海屏适配
android·开发语言·kotlin