GitHub Demo项目链接:https://github.com/DoubleD0721/Screenshot
目前Android针对截屏的监控主要有三种方式:
本文主要使用ContentObserver的方式来实现对截屏的监控。
主要针对Android 13及Android 14更新的存储权限进行适配。
在Android 13中,存储权限从原来的READ\_EXTERNAL\_STORAGE
细化成为READ\_MEDIA\_IMAGES
/READ\_MEDIA\_VIDEO
/READ\_MEDIA\_AUDIO
三种权限,在进行权限判断的时候需要进行版本区分。
在Android 14中,存储权限从Android 13的细化权限中更新成为允许用户选择部分图片资源给应用访问。但是针对截屏增加了一个新的截屏监控权限DETECT\_SCREEN\_CAPTURE
,该权限默认为开且用户无感知,针对用户只给部分权限的情况,我们可以通过该权限来获取用户的截屏动作,尝试一些不依赖截屏文件的操作。
|权限状态|Android 13及以下机型|Android 14及以上机型|
|----|----|---|
|有全部相册权限|使用媒体库监控实现监控|使用媒体库监控实现监控
|有部分相册权限|无法进行监控|使用系统API进行监控(但无法拿到截屏文件)
|没有相册权限|无法进行监控|使用系统API进行监控(但无法拿到截屏文件)
针对Android 13及以下用户,使用监听媒体库方式进行截屏的监控
private inner class MediaContentObserver(private val contentUri: Uri, handler: Handler?) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
// handle screenshot file
}
}
在合适时机,通过registerActivityLifecycleCallbacks
的方法将截屏的开始监控及取消监控注入到每个activity的生命周期中。将开始监控媒体库方法注入每个activity的onResume
中,将停止监控注入每个activity的onPause
中,保证activity在展示的时候开始监控截屏,在消失的时候结束对截屏的监控。
application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
CoroutineScope(Dispatchers.IO).launch {
startListen(WeakReference(activity))
}
}
override fun onActivityPaused(activity: Activity) {
CoroutineScope(Dispatchers.IO).launch {
stopListen(WeakReference(activity))
}
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityDestroyed(activity: Activity) {}
})
如果不希望这样实现,也可以直接将相关能力注入到需要被监控activity的生命周期中,而不是所有的activity。
在对应的生命周期中实现对媒体库的绑定与解绑。
private fun registerObserver(activity: Activity) {
internalObserver = MediaContentObserver(MediaStore.Images.Media.INTERNAL\_CONTENT\_URI, uiHandler)
externalObserver = MediaContentObserver(MediaStore.Images.Media.EXTERNAL\_CONTENT\_URI, uiHandler)
startListenTime = System.currentTimeMillis()
internalObserver?.let {
activity.applicationContext.contentResolver.registerContentObserver(
MediaStore.Images.Media.INTERNAL\_CONTENT\_URI,
Build.VERSION.SDK\_INT > Build.VERSION\_CODES.P, it,
)
}
externalObserver?.let {
activity.applicationContext.contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL\_CONTENT\_URI,
Build.VERSION.SDK\_INT > Build.VERSION\_CODES.P, it,
)
}
}
private fun unregisterObserver(activity: Activity) {
try {
internalObserver?.let {
activity.contentResolver.unregisterContentObserver(it)
}
} catch (\_: Exception) {}
internalObserver = null
try {
externalObserver?.let {
activity.contentResolver.unregisterContentObserver(it)
}
} catch (\_: Exception) {}
externalObserver = null
startListenTime = 0
}
获取最新文件主要通过contentResolver通过DATE_MODIFIED来倒序获取第一个
private fun getContentResolverCursor(
contentUri: Uri,
context: Context,
maxCount: Int = 1
) = if (Build.VERSION.SDK\_INT >= Build.VERSION\_CODES.Q) {
val bundle = Bundle().apply {
putStringArray(ContentResolver.QUERY\_ARG\_SORT\_COLUMNS, arrayOf(MediaStore.Images.ImageColumns.DATE\_MODIFIED))
putInt(ContentResolver.QUERY\_ARG\_SORT\_DIRECTION, ContentResolver.QUERY\_SORT\_DIRECTION\_DESCENDING)
putInt(ContentResolver.QUERY\_ARG\_LIMIT, maxCount)
}
context.contentResolver.query(
contentUri,
MEDIA\_PROJECTIONS\_API\_16,
bundle,
null,
)
} else {
context.contentResolver.query(
contentUri,
MEDIA\_PROJECTIONS,
null,
null,
"${MediaStore.Images.ImageColumns.DATE\_MODIFIED} desc limit ${maxCount}",
)
}
其中,针对不同版本的Android机型,获取的字段也做了相应的处理
val MEDIA\_PROJECTIONS\_API\_16 = arrayOf(
MediaStore.Images.ImageColumns.DATA,
MediaStore.Images.ImageColumns.DATE\_ADDED,
MediaStore.Images.ImageColumns.WIDTH,
MediaStore.Images.ImageColumns.HEIGHT,
)
val MEDIA\_PROJECTIONS = arrayOf(
MediaStore.Images.ImageColumns.DATA,
MediaStore.Images.ImageColumns.DATE\_ADDED,
)
判断是否为截屏文件主要通过以下三个维度来进行判断
判断获取到的文件路径是否包含screenshot相关字段
private fun isFilePathLegal(filePath: String?): Boolean {
// File path is not empty
if (filePath == null || TextUtils.isEmpty(filePath)) {
return false
}
// File path contains screenshot KEYWORDS
var hasValidScreenShot = false
val lowerPath = filePath.lowercase(Locale.getDefault())
for (keyWork: String in KEYWORDS) {
if (lowerPath.contains(keyWork)) {
hasValidScreenShot = true
break
}
}
其中的关键字包括:
private val KEYWORDS = arrayOf(
"screenshot", "screen\_shot", "screen-shot", "screen shot",
"screencapture", "screen\_capture", "screen-capture", "screen capture",
"screencap", "screen\_cap", "screen-cap", "screen cap",
)
判断文件创建的时间是否晚于开始监听截屏的时间同时文件创建的时间和当前时间相差小于10s
private fun isFileSizeLegal(width: Int?, height: Int?) =
screenRealSize?.let {
if (width == null || height == null) {
false
} else if (!((width <= it.x && height <= it.y) || (height <= it.x && width <= it.y))) {
false
} else {
true
}
} ?: false
判断获取图片的大小和手机尺寸的大小是否一致
private fun isFileSizeLegal(width: Int?, height: Int?) =
screenRealSize?.let {
if (width == null || height == null) {
false
} else if (!((width <= it.x && height <= it.y) || (height <= it.x && width <= it.y))) {
warn { "error: size" }
false
} else {
true
}
} ?: false
下面是获取屏幕尺寸的方法
private fun getRealScreenSize(context: Context): Point? {
var screenSize: Point? = null
try {
screenSize = Point()
val windowManager = context.getSystemService(Context.WINDOW\_SERVICE) as WindowManager
val defaultDisplay = windowManager.defaultDisplay
if (Build.VERSION.SDK\_INT >= Build.VERSION\_CODES.JELLY\_BEAN\_MR1) {
defaultDisplay.getRealSize(screenSize)
} else {
try {
val rawWidth = Display::class.java.getMethod("getRawWidth")
val rawHeight = Display::class.java.getMethod("getRawHeight")
screenSize.set(
(rawWidth.invoke(defaultDisplay) as Int),
(rawHeight.invoke(defaultDisplay) as Int),
)
} catch (\_: Exception) {
screenSize.set(defaultDisplay.width, defaultDisplay.height)
}
}
} catch (\_: Exception) { }
return screenSize
}
当判断为是截屏文件后,对截屏文件进行处理,这里通过一个全局变量的listener来控制监听到截屏后的动作,针对不同的场景对listener做动态的更新。
完整的截屏文件判断流程:
fun handleMediaContentChange(
contentUri: Uri,
context: Context?,
startListenTime: Long?
) {
CoroutineScope(Dispatchers.IO).launch {
if (context == null) return@launch
if (screenRealSize == null) screenRealSize = getRealScreenSize(context)
var cursor: Cursor? = null
try {
cursor = getContentResolverCursor(contentUri, context)
} catch (\_: Exception) { }
if (cursor == null || !cursor.moveToFirst()) return@launch
// Get all of colum index
with(cursor) {
val dataIndex = getColumnIndex(MediaStore.Images.ImageColumns.DATA) ?: -1
val dateAddedIndex = getColumnIndex(MediaStore.Images.ImageColumns.DATE\_ADDED) ?: -1
val widthIndex = getColumnIndex(MediaStore.Images.ImageColumns.WIDTH) ?: -1
val heightIndex = getColumnIndex(MediaStore.Images.ImageColumns.HEIGHT) ?: -1
// Handle media row data
// File path
val filePath = cursor.getScreenShotFilePath(dataIndex)
if (!isFilePathLegal(filePath)) return@with
// File Date Added
val dateAdded = cursor.getScreenShotFileDateAdded(dateAddedIndex)
if (!isFileCreationTimeLegal(dateAdded, startListenTime)) return@with
// File Size
val (width, height) = cursor.getScreenShotFileSize(filePath, widthIndex, heightIndex)
if (!isFileSizeLegal(width, height)) return@with
handleScreenShot(filePath)
}
if (!cursor.isClosed) cursor.close()
}
}
使用系统APIActivity.ScreenCaptureCallback
进行监控,但是由于没有全部相册权限获取不到截屏文件的具体路径,所以只能实现一些不依赖路径的动作(如埋点上报等)
private var screenShotCaptureCallback = if (Build.VERSION.SDK\_INT >= Build.VERSION\_CODES.UPSIDE\_DOWN\_CAKE) {
Activity.ScreenCaptureCallback {
// handle screenshot
}
} else null
在activity启动的时候开始对截屏进行监听,在activity消失的时候结束对截屏的监听,时机与使用媒体库监听时机一样
private fun registerCallback(activity: Activity) {
if (Build.VERSION.SDK\_INT >= Build.VERSION\_CODES.UPSIDE\_DOWN\_CAKE) {
screenShotCaptureCallback?.let { callback ->
activity.registerScreenCaptureCallback(activity.mainExecutor, callback)
}
}
}
private fun unregisterCallback(activity: Activity) {
if (Build.VERSION.SDK\_INT >= Build.VERSION\_CODES.UPSIDE\_DOWN\_CAKE) {
screenShotCaptureCallback?.let { callback ->
try {
activity.unregisterScreenCaptureCallback(callback)
startListenTime = 0
} catch (\_: IllegalStateException) {}
}
}
}
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。