一、支持网页视频播放
设置自定义WebChromeClient,覆写 onShowCustomView
、onHideCustomView
,将WebChromeClient提供的 view 添加到布局中即可。
webChromeClient = object : WebChromeClient() { override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE mViewBinding.flVideoContainer.run { visibility = View.VISIBLE addView(view) } } override fun onHideCustomView() { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR mViewBinding.flVideoContainer.run { visibility = View.GONE removeAllViews() } } }
二、长按保存图片
首先我们为 WebView 设置长按点击事件监听,通过调用 WebView#getHitTestResult()
获取当前点击类型。
setOnLongClickListener { val hitResult = hitTestResult when (hitResult.type) { WebView.HitTestResult.IMAGE_TYPE, WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE -> { saveToGallery(hitResult.extra) true } else -> false } }
接下来弹窗展示选项,这里使用了 AlertDialog。
private fun saveToGallery(url: String?) { AlertDialog.Builder(this@MainActivity) .setItems(arrayOf("保存到相册")) { dialog, _ -> dialog.dismiss() url.run str@{ if (isNullOrBlank()) { Toast.makeText( this@MainActivity, "保存失败, 图片路径为空", Toast.LENGTH_LONG ).show() return@str } val uri = toUri() when (uri.scheme) { "http", "https" -> saveUrlImage(this) "data" -> saveBase64Image(uri.schemeSpecificPart) else -> Toast.makeText( this@MainActivity, "保存失败, 不支持的格式", Toast.LENGTH_LONG ).show() } } } .create() .show() }
图片 Uri 分为 http、data 格式进行加载。
Uri Data Scheme 格式如下:
data:①[<mime type>]②[;charset=<charset>]③[;<encoding>]④,<encoded data>⑤
①. data :协议名称;
②. [<mime type>] :可选项,数据类型(image/png、text/plain等)
③. [;charset=<charset>] :可选项,源文本的字符集编码方式
④. [;<encoding>] :数据编码方式(默认US-ASCII,BASE64两种)
⑤. ,<encoded data> :编码后的数据
例如 data:image/jpeg;base64,aGFoYQ==
这里使用 URLConnection 获取网络资源,Kotlin协程进行异步操作。
private fun saveUrlImage(url: String) = launch(Dispatchers.IO) { try { URL(url).openStream().use { val fileName = url.substringAfterLast('/') val extension = fileName.substringAfterLast('.') insertImageToMediaStore( "image_${fileName}", "image/${if (extension.isEmpty()) "jpeg" else extension}", it.readBytes() ) } } catch (e: IOException) { e.printStackTrace() showToast("保存失败, 网络连接错误") } } private fun saveBase64Image(resource: String) = launch(Dispatchers.IO) { val mimeType = resource.substringBefore(';', "") val type = mimeType.split('/') if (type.size != 2) { showToast("保存失败") return@launch } if (type[0] != "image") { showToast("保存失败, 不支持的类型") return@launch } val base64 = resource.substringAfter("base64,", "") if (base64.isEmpty()) { showToast("保存失败, 不支持的数据格式") return@launch } val bytes = Base64.decode(base64, Base64.DEFAULT) val date = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.CHINA).format(Date()) insertImageToMediaStore("image_$date.${type[1]}", mimeType, bytes) }
最后通过 ContentResolver
保存上一步获取的字节数组即可。
private suspend fun insertImageToMediaStore(title: String, mimeType: String, bytes: ByteArray) = withContext(Dispatchers.IO) { val insertValues = ContentValues().apply { val now = System.currentTimeMillis() / 1000 put(MediaColumns.DISPLAY_NAME, title) put(MediaColumns.MIME_TYPE, mimeType) put(MediaColumns.DATE_ADDED, now) put(MediaColumns.DATE_MODIFIED, now) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaColumns.IS_PENDING, 1) put( MediaColumns.DATE_EXPIRES, (System.currentTimeMillis() + DateUtils.DAY_IN_MILLIS) / 1000 ) } } val contentResolver = contentResolver val pendingUri: Uri? = contentResolver.insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, insertValues ) if (pendingUri == null) { showToast("保存失败") return@withContext } try { contentResolver.openOutputStream(pendingUri).use { if (it == null) { showToast("保存失败") return@use } it.write(bytes) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { insertValues.put(MediaColumns.IS_PENDING, 0) insertValues.putNull(MediaColumns.DATE_EXPIRES) contentResolver.update(pendingUri, insertValues, null, null) } showToast("保存成功") } catch (e: Exception) { contentResolver.delete(pendingUri, null, null) showToast("保存失败") } }