一、支持网页视频播放
设置自定义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("保存失败")
}
}
