Android 截屏分享

前言

12月中旬产品提出了一个需求,截屏分享的功能。我想这个需求网上已经一大堆文章了。所以这里我就大致说一下。

解决方案

1、FileObserver监听截图文件目录数据改变。
2、ContentProvider监听数据的改变。

FileObserver

不熟悉FileObserver的同学请点击这里,采用FileObserver方式
则需要根据厂商所在的截屏文件文件夹路径进行适配,这点就有点烦哦。所以最终我选择ContentProvider的方式监听文件数据的变动。

ContentObserver

ContentProvider用于将应用数据共享出去,ContentObserver 内容观察者用于获取共享数据,使用它即可监听到数据的变更。

创建内容观察者对象

public class CaptureFileObserver extends ContentObserver {
    private final Uri mContentUri;
    private final CaptureCallback mCaptureCallback;

    public CaptureFileObserver(Uri contentUri, CaptureCallback captureCallback, Handler handler) {
        super(handler);
        mCaptureCallback = captureCallback;
        mContentUri = contentUri;
    }
    @Override
    public void onChange(boolean selfChange, Uri uri) {
        super.onChange(selfChange, uri);
        // 触发了截屏 注意这里会多次回调
        if (mCaptureCallback != null){
            mCaptureCallback.onMediaFileChanged(mContentUri);
        }
    }
    /**
     * 内容观察者回调事件
     */
    public interface CaptureCallback {

        void onMediaFileChanged(Uri contentUri);
    }
}

当数据发生变化之后,将会回调onChange()方法通知我们数据发生了变化。

注册内容观察者

public abstract class MediaFileBaseObserver implements CaptureFileObserver.CaptureCallback {
    protected Context mContext;
    private final Handler mHandler = new Handler(Looper.getMainLooper());
    /**
     * 获取截屏事件回调
     */
    protected CaptureCallback mCaptureCallback;
    private final CaptureFileObserver mCaptureInternalFileObserver;
    private final CaptureFileObserver mCaptureExternalFileObserver;
    private final Uri[] mContentUris = {Media.INTERNAL_CONTENT_URI, Media.EXTERNAL_CONTENT_URI};
    protected final ContentResolver mContentResolver;
    protected long mStartListenTime;
    public MediaFileBaseObserver(Context context) {
        mContext = context;
        mContentResolver = mContext.getContentResolver();
        // 内部外部媒体文件的监听
        mCaptureInternalFileObserver = new CaptureFileObserver(mContentUris[0], this, mHandler);
        mCaptureExternalFileObserver = new CaptureFileObserver(mContentUris[1], this, mHandler);
    }
    /**
     * 开始进行捕捉截屏监听
     */
    public void registerCaptureListener(){
        // 记录开始监听的时间 算是一个图片是否是截屏的一个指标
        mStartListenTime = System.currentTimeMillis();
        // 注意 第二个boolean参数 要设置为true 不然有些机型由于多媒体文件层级不同 导致变化监听不到 所以设置后代文件夹发生了文件改变也要进行通知
        mContentResolver.registerContentObserver(mContentUris[0],true, mCaptureInternalFileObserver);
        mContentResolver.registerContentObserver(mContentUris[1],true, mCaptureExternalFileObserver);
    }
    /**
     * 解除绑定
     */
    public void unregisterCaptureListener(){
        mContentResolver.unregisterContentObserver(mCaptureInternalFileObserver);
        mContentResolver.unregisterContentObserver(mCaptureExternalFileObserver);
    }
    /**
     * 设置回调监听
     * @param captureCallback 回调
     */
    public void setCaptureCallbackListener(CaptureCallback captureCallback){
        mCaptureCallback = captureCallback;
    }
    @Override
    public void onMediaFileChanged(Uri contentUri) {
        acquireTargetFile(contentUri);
    }
    /**
     * 获取目标的文件
     * @param contentUri 内容URI
     */
    abstract void acquireTargetFile(Uri contentUri);
}

这里我们对外部存储图片文件夹和内部存储图片文件夹进行注册监听。若发生了文件变化,则从这两个路径中拿所有的图片文件路径,并且进行按照图片的添加顺序进行降序排序并且限制数量为1,也就是说取第一张图片。

内部存储
content://media/internal/images/media
外部存储
content://media/external/images/media
public class MediaImageObserver extends MediaFileBaseObserver {
    private static final String TAG = MediaImageObserver.class.getSimpleName();
    @SuppressLint("StaticFieldLeak")
    private static volatile MediaImageObserver mInstance = null;
    private static final String[] MEDIA_STORE_IMAGE = {
            MediaStore.Images.ImageColumns.DATA,
            // 时间 这里不能用 Date_ADD 因为是秒级 按时间筛选不准确
            MediaStore.Images.ImageColumns.DATE_TAKEN,
            // 宽
            MediaStore.Images.ImageColumns.WIDTH
    };
    // 截屏关键词 随时补充
    private static final String[] KEYWORDS = {
            "screenshot", "screen_shot", "screen-shot", "screen shot",
            "screencapture", "screen_capture", "screen-capture", "screen capture",
            "screencap", "screen_cap", "screen-cap", "screen cap", "Screenshot","截屏"
    };
    // 按照日期插入的顺序取第一条
    private final static String QUERY_ORDER_SQL = ImageColumns.DATE_ADDED + " DESC LIMIT 1";
    private final Point mPoint;
    public static MediaFileBaseObserver getInstance(Application application) {
        if (mInstance == null) {
            synchronized (MediaFileBaseObserver.class) {
                if (mInstance == null) {
                    mInstance = new MediaImageObserver(application.getApplicationContext());
                }
            }
        }
        return mInstance;
    }
    public MediaImageObserver(Context context) {
        super(context);
        mPoint = ScreenUtil.getRealScreenSize(context);
    }
    @Override
    void acquireTargetFile(Uri contentUri) {
        Cursor cursor = null;
        try {
            if (VERSION.SDK_INT >= VERSION_CODES.Q) {
                Bundle bundle = new Bundle();
                // 按照文件时间
                bundle.putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, new String[]{FileColumns.DATE_TAKEN});
                // 降序
                bundle.putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING);
                // 取第一张
                bundle.putInt(ContentResolver.QUERY_ARG_LIMIT, 1);
                cursor = mContentResolver.query(contentUri, MEDIA_STORE_IMAGE, bundle,null);
            } else {
                // 查找
                cursor = mContentResolver.query(contentUri, MEDIA_STORE_IMAGE, null, null, QUERY_ORDER_SQL);
            }
            findImagePathByCursor(cursor);
        } catch (Exception e) {
            if (e.getMessage() != null) {
                Log.e(TAG, e.getMessage());
            } else {
                e.printStackTrace();
            }
        }finally {
            if (cursor != null && !cursor.isClosed()){
                cursor.close();
            }
        }
    }
    private void findImagePathByCursor(Cursor cursor) {
        if (cursor == null) {
            return;
        }
        if (!cursor.moveToFirst()){
            Log.d(TAG,"Cannot find newest image file");
            return;
        }
        // 获取 文件索引
        int imageColumnIndexData = cursor.getColumnIndex(ImageColumns.DATA);
        int imageCreateDateIndexData = cursor.getColumnIndex(ImageColumns.DATE_TAKEN);
        int imageWidthColumnIndexData = cursor.getColumnIndex(ImageColumns.WIDTH);
        String imagePath = cursor.getString(imageColumnIndexData);
        int imageWidth = cursor.getInt(imageWidthColumnIndexData);
        long imageCreateDate = cursor.getLong(imageCreateDateIndexData);
        // 时间判断 判断截屏时间 与 截屏图片实际生成时间的差
        if (imageCreateDate < mStartListenTime) {
           return;
        }
        // 这里只判断width 长截屏无法判断
        if (mPoint != null && mPoint.x != imageWidth){
            return;
        }
        // path 为空
        if (TextUtils.isEmpty(imagePath)){
            return;
        }
        // 判断关键词
        String lowerCasePath = imagePath.toLowerCase();
        // 关键词比对
        for (String keyword : KEYWORDS) {
            if (lowerCasePath.contains(keyword)){
                if (mCaptureCallback != null) {
                    mCaptureCallback.capture(imagePath);
                }
                break;
            }
        }
    }
}

代码很简单,不过有个坑在于当我们采用以下的查询方法的时候,在编译版本30,Android 11机型下,会报一个异常。

private final static String QUERY_ORDER_SQL = ImageColumns.DATE_ADDED + " DESC LIMIT 1";
mContentResolver.query(contentUri, MEDIA_STORE_IMAGE, null, null, QUERY_ORDER_SQL);

SQL 异常.png

费了一番查找最终找到,若在Android 11 版本后进行共享数据的查询,需要使用ContentReslover#query()方法参数为Bundle的方法,查看官方文档,将查询条件使用Bundle组装并跨进程传输。详细问题解决方案

总结

截屏分享Android原生并没有提供相关的Api,让我们获取,但是解决办法还是有的,就是通过ContentObserver进行对内外存储文件的变动的监听,之后根据ContentResolver进行Query查询,并进行排序筛选,在进行二次一系列的条件筛选,最终找到我们那张截图的图片。

补充 2021/02/05

问题1

在应用到实际项目中时,发现当应用退出到后台时,用户截取图片的时候,会将非该应用的截图响应到自己的应用中,并触发分享,这导致分享不合乎逻辑。
解决办法在感知到文件系统发生变化时,判断一下当前应用是否处于前台即可。

/**
     * 判断app是否在后台啊
     *
     * @return 0 在后台 1 在前台 2 不存在
     */
    public static int isBackground(Context context) {
        ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
        List<RunningAppProcessInfo> appProcesses = activityManager.getRunningAppProcesses();
        for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) {
            if (appProcess.processName.equals(context.getPackageName())) {
                if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_CACHED) {
                    return 2;
                } else if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
                    return 1;
                }
            }
        }
        return 2;
    }

问题2

在某些低端机型,比如红米6等,由于使用数据库查询cursor 比较慢,导致分享回调有延迟,用户可能跳转到其他的界面了,才展示弹窗,影响用户体验,因此这边做了一个等待延迟条件,判断当前时间与最终截图回调时间做对比,设定一个阈值拦截。

问题3

截屏黑名单,有些界面涉及到用户敏感信息,所以就不触发用户截屏。

 @Override
protected void onCreate(Bundle savedInstanceState) {
    // 禁掉截屏
    getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
    super.onCreate(savedInstanceState);
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容