Android 監聽系統截圖操作,適配Android Q(29)

語言: CN / TW / HK

在Android App中監聽系統截圖功能,沒有系統標準的監聽器或者api可以呼叫,需要自己實現。

針對這個需求,目前大部分實現方案是監聽系統的媒體資料庫。

原理:每當產生一張新圖片,系統都會把這張圖片的詳細資訊加入到媒體資料庫,併發出內容改變通知。

實現:利用內容觀察者(ContentObserver)監聽媒體資料庫的變化,當資料庫有變化時,獲取最後插入的一條圖片資料,如果該圖片符合特定的規則,則認為使用者截圖了。

監聽兩個Uri

  • 內部儲存空間的 content:// 格式Uri:MediaStore.Images.Media.INTERNAL_CONTENT_URI
  • 主要外部儲存空間 content:// 格式Uri:MediaStore.Images.Media.EXTERNAL_CONTENT_URI

許可權:開始監聽媒體資料庫變化之前,需要先獲取許可權READ_EXTERNAL_STORAGE

實現步驟

1、定義一個內容觀察者類:

``` /* * 媒體內容觀察者(觀察媒體資料庫的改變) / private class MediaContentObserver extends ContentObserver {

private Uri mContentUri;

public MediaContentObserver(Uri contentUri, Handler handler) {
    super(handler);
    mContentUri = contentUri;
}

@Override
public void onChange(boolean selfChange) {
    LogUtils.e("ScreenShotListenManager  MediaContentObserver  onChange");
    super.onChange(selfChange);
    //通過媒體資料庫的內容改變來判斷使用者是否執行了截圖操作
}

} ```

2、建立並註冊內容觀察者

``` // 建立內容觀察者 mInternalObserver = new MediaContentObserver(MediaStore.Images.Media.INTERNAL_CONTENT_URI,mUiHandler); mExternalObserver = new MediaContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,mUiHandler);

// 註冊內容觀察者 mContext.getContentResolver().registerContentObserver( MediaStore.Images.Media.INTERNAL_CONTENT_URI, Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q, //Android Q適配 mInternalObserver ); mContext.getContentResolver().registerContentObserver( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q, //Android Q適配 mExternalObserver ); ```

Build.VERSION_CODES.Q(29),編譯的sdk需要>=29

3、不需要監聽內容變化時,一定要登出內容觀察者

// 登出內容觀察者 if (mInternalObserver != null) { try { mContext.getContentResolver().unregisterContentObserver(mInternalObserver); } catch (Exception e) { e.printStackTrace(); } mInternalObserver = null; } if (mExternalObserver != null) { try { mContext.getContentResolver().unregisterContentObserver(mExternalObserver); } catch (Exception e) { e.printStackTrace(); } mExternalObserver = null; }

當媒體內容發生變化時,會執行ContentObserver 的onChange方法。我們需要通過媒體資料庫的內容改變來判斷使用者是否執行了截圖操作: 獲取最近插入的一條資料,判斷是否符合截圖圖片的特徵。如果符合,那麼我們認為使用者進行了截圖操作。

``` public class ScreenShotListenManager { private static final String TAG = "ScreenShotListenManager";

/**
 * 讀取媒體資料庫時需要讀取的列
 */
private static final String[] MEDIA_PROJECTIONS = {
        MediaStore.Images.ImageColumns.DATA,
        MediaStore.Images.ImageColumns.DATE_TAKEN,
};
/**
 * 讀取媒體資料庫時需要讀取的列, 其中 WIDTH 和 HEIGHT 欄位在 API 16 以後才有
 */
private static final String[] MEDIA_PROJECTIONS_API_16 = {
        MediaStore.Images.ImageColumns.DATA,
        MediaStore.Images.ImageColumns.DATE_TAKEN,
        MediaStore.Images.ImageColumns.WIDTH,
        MediaStore.Images.ImageColumns.HEIGHT,
};

/**
 * 截圖依據中的路徑判斷關鍵字
 */
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"
};

private static Point sScreenRealSize;

/**
 * 已回撥過的路徑
 */
private final static List<String> sHasCallbackPaths = new ArrayList<String>();

private Context mContext;

private OnScreenShotListener mListener;

private long mStartListenTime;

/**
 * 內部儲存器內容觀察者
 */
private MediaContentObserver mInternalObserver;

/**
 * 外部儲存器內容觀察者
 */
private MediaContentObserver mExternalObserver;

/**
 * 執行在 UI 執行緒的 Handler, 用於執行監聽器回撥
 */
private final Handler mUiHandler = new Handler(Looper.getMainLooper());

private ScreenShotListenManager(Context context) {
    if (context == null) {
        throw new IllegalArgumentException("The context must not be null.");
    }
    mContext = context;

    // 獲取螢幕真實的解析度
    if (sScreenRealSize == null) {
        sScreenRealSize = getRealScreenSize();
        if (sScreenRealSize != null) {
            Log.d(TAG, "Screen Real Size: " + sScreenRealSize.x + " * " + sScreenRealSize.y);
        } else {
            Log.e(TAG, "Get screen real size failed.");
        }
    }
}

public static ScreenShotListenManager newInstance(Context context) {
    assertInMainThread();
    return new ScreenShotListenManager(context);
}

/**
 * 啟動監聽
 */
public void startListen() {
    assertInMainThread();

    // 記錄開始監聽的時間戳
    mStartListenTime = System.currentTimeMillis();

    // 建立內容觀察者
    mInternalObserver = new MediaContentObserver(MediaStore.Images.Media.INTERNAL_CONTENT_URI, mUiHandler);
    mExternalObserver = new MediaContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mUiHandler);

    // 註冊內容觀察者
    mContext.getContentResolver().registerContentObserver(
            MediaStore.Images.Media.INTERNAL_CONTENT_URI,
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
            mInternalObserver
    );
    mContext.getContentResolver().registerContentObserver(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
            mExternalObserver
    );
    LogUtils.e("ScreenShotListenManager  startListen");
}

/**
 * 停止監聽
 */
public void stopListen() {
    assertInMainThread();

    // 登出內容觀察者
    if (mInternalObserver != null) {
        try {
            mContext.getContentResolver().unregisterContentObserver(mInternalObserver);
        } catch (Exception e) {
            e.printStackTrace();
        }
        mInternalObserver = null;
    }
    if (mExternalObserver != null) {
        try {
            mContext.getContentResolver().unregisterContentObserver(mExternalObserver);
        } catch (Exception e) {
            e.printStackTrace();
        }
        mExternalObserver = null;
    }

    // 清空資料
    mStartListenTime = 0;

// sHasCallbackPaths.clear();

    //切記!!!:必須設定為空 可能mListener 會隱式持有Activity導致釋放不掉
    mListener = null;
}

/**
 * 處理媒體資料庫的內容改變
 */
private void handleMediaContentChange(Uri contentUri) {
    Cursor cursor = null;
    try {
        // 資料改變時查詢資料庫中最後加入的一條資料
        cursor = mContext.getContentResolver().query(
                contentUri,
                Build.VERSION.SDK_INT < 16 ? MEDIA_PROJECTIONS : MEDIA_PROJECTIONS_API_16,
                null,
                null,
                MediaStore.Images.ImageColumns.DATE_ADDED + " desc limit 1"
        );

        if (cursor == null) {
            Log.e(TAG, "Deviant logic.");
            return;
        }
        if (!cursor.moveToFirst()) {
            Log.d(TAG, "Cursor no data.");
            return;
        }

        // 獲取各列的索引
        int dataIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA);
        int dateTakenIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATE_TAKEN);
        int widthIndex = -1;
        int heightIndex = -1;
        if (Build.VERSION.SDK_INT >= 16) {
            widthIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.WIDTH);
            heightIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.HEIGHT);
        }

        // 獲取行資料
        String data = cursor.getString(dataIndex);
        long dateTaken = cursor.getLong(dateTakenIndex);
        int width = 0;
        int height = 0;
        if (widthIndex >= 0 && heightIndex >= 0) {
            width = cursor.getInt(widthIndex);
            height = cursor.getInt(heightIndex);
        } else {
            // API 16 之前, 寬高要手動獲取
            Point size = getImageSize(data);
            width = size.x;
            height = size.y;
        }

        // 處理獲取到的第一行資料
        handleMediaRowData(data, dateTaken, width, height);

    } catch (Exception e) {
        e.printStackTrace();

    } finally {
        if (cursor != null && !cursor.isClosed()) {
            cursor.close();
        }
    }
}

/**
  * 獲取圖片寬和高
  */
private Point getImageSize(String imagePath) {
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeFile(imagePath, options);
    return new Point(options.outWidth, options.outHeight);
}

/**
 * 處理獲取到的一行資料
 */
private void handleMediaRowData(String data, long dateTaken, int width, int height) {
    if (CommunityApplication.self.isComeBack()) {
        return;
    }
    if (checkScreenShot(data, dateTaken, width, height)) {
        Log.d(TAG, "ScreenShot: path = " + data + "; size = " + width + " * " + height
                + "; date = " + dateTaken);
        if (mListener != null && !checkCallback(data)) {
            mListener.onShot(data);
        }
    } else {
        // 如果在觀察區間媒體資料庫有資料改變,又不符合截圖規則,則輸出到 log 待分析
        Log.w(TAG, "Media content changed, but not screenshot: path = " + data
                + "; size = " + width + " * " + height + "; date = " + dateTaken);
    }
}

/**
 * 判斷指定的資料行是否符合截圖條件
 */
private boolean checkScreenShot(String data, long dateTaken, int width, int height) {
    /*
     * 判斷依據一: 時間判斷
     */
    // 如果加入資料庫的時間在開始監聽之前, 或者與當前時間相差大於10秒, 則認為當前沒有截圖
    if (dateTaken < mStartListenTime || (System.currentTimeMillis() - dateTaken) > 10 * 1000) {
        return false;
    }

    /*
     * 判斷依據二: 尺寸判斷
     */
    if (sScreenRealSize != null) {
        // 如果圖片尺寸超出螢幕, 則認為當前沒有截圖
        if (!((width <= sScreenRealSize.x && height <= sScreenRealSize.y)
                || (height <= sScreenRealSize.x && width <= sScreenRealSize.y))) {
            return false;
        }
    }

    /*
     * 判斷依據三: 路徑判斷
     */
    if (TextUtils.isEmpty(data)) {
        return false;
    }
    data = data.toLowerCase();
    // 判斷圖片路徑是否含有指定的關鍵字之一, 如果有, 則認為當前截圖了
    for (String keyWork : KEYWORDS) {
        if (data.contains(keyWork)) {
            return true;
        }
    }

    return false;
}

/**
 * 判斷是否已回撥過, 某些手機ROM截圖一次會發出多次內容改變的通知; <br/>
 * 刪除一個圖片也會發通知, 同時防止刪除圖片時誤將上一張符合截圖規則的圖片當做是當前截圖.
 */
private boolean checkCallback(String imagePath) {
    if (sHasCallbackPaths.contains(imagePath)) {
        Log.d(TAG, "ScreenShot: imgPath has done"
                + "; imagePath = " + imagePath);
        return true;
    }
    // 大概快取15~20條記錄便可
    if (sHasCallbackPaths.size() >= 20) {
        for (int i = 0; i < 5; i++) {
            sHasCallbackPaths.remove(0);
        }
    }
    sHasCallbackPaths.add(imagePath);
    return false;
}

/**
 * 獲取螢幕解析度
 */
private Point getRealScreenSize() {
    Point screenSize = null;
    try {
        screenSize = new Point();
        WindowManager windowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
        if (windowManager != null) {
            Display defaultDisplay = windowManager.getDefaultDisplay();
            defaultDisplay.getRealSize(screenSize);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return screenSize;
}

/**
 * 設定截圖監聽器
 */
public void setListener(OnScreenShotListener listener) {
    mListener = listener;
}

public interface OnScreenShotListener {
    void onShot(String imagePath);
}

private static void assertInMainThread() {
    if (Looper.myLooper() != Looper.getMainLooper()) {
        StackTraceElement[] elements = Thread.currentThread().getStackTrace();
        String methodMsg = null;
        if (elements != null && elements.length >= 4) {
            methodMsg = elements[3].toString();
        }
        throw new IllegalStateException("Call the method must be in main thread: " + methodMsg);
    }
}

/**
 * 媒體內容觀察者(觀察媒體資料庫的改變)
 */
private class MediaContentObserver extends ContentObserver {

    private Uri mContentUri;

    public MediaContentObserver(Uri contentUri, Handler handler) {
        super(handler);
        mContentUri = contentUri;
    }

    @Override
    public void onChange(boolean selfChange) {
        LogUtils.e("ScreenShotListenManager  MediaContentObserver  onChange");
        super.onChange(selfChange);
        handleMediaContentChange(mContentUri);
    }
}

} ```

Android Q版本無法檢測到媒體資料庫變化的問題

Android Q(10) ContentObserver 不回撥 onChange這篇文章提供的解決方法:在Android Q版本上呼叫註冊媒體資料庫監聽的方法registerContentObserver時傳入 notifyForDescendants引數值改為 true,Android Q之前的版本仍然傳入 false。

於是查看了一下文件上關於引數notifyForDescendants的介紹,大致內容如下:

* @param notifyForDescendants When false, the observer will be notified * whenever a change occurs to the exact URI specified by * <code>uri</code> or to one of the URI's ancestors in the path * hierarchy. When true, the observer will also be notified * whenever a change occurs to the URI's descendants in the path * hierarchy.

Descendants:後裔、後代

如果值為false,則只要指定的URI或路徑層次結構中URI的祖先之一發生變化,就會通知觀察者。 如果為true,則每當路徑層次結構中URI的後代發生更改時,也會通知觀察者。

參考:

http://www.jianshu.com/p/a7fab8faa73c