17 October 2017

จะทำยังไงให้ App รู้ได้ว่าผู้ใช้กด Screenshot ระหว่างเปิด App กันอยู่นะ?

Updated on

        ไม่นานมานี้ เจ้าของบล็อกได้สังเกตเห็นว่า Instagram หรือ Facebook นั้นมีการเพิ่มฟีเจอร์ตรวจจับการ Screenshot ของผู้ใช้ได้แล้ว ถ้าผู้ใช้กด Screenshot ภาพของใครซักคนในนั้น ก็จะมีการแจ้งเตือนไปที่เจ้าของภาพคนนั้นๆด้วย จึงทำให้เจ้าของบล็อกสงสัยมากมายว่าทำได้ยังไง วันนี้ก็เลยจะมาเล่าเกี่ยวกับเรื่องนี้ให้อ่านกันครับ

ทำได้ยังไงน่ะ? API ของ Android ไม่มี Event สำหรับ Screenshot นี่นา

        ใช่ครับ นักพัฒนาหลายๆคนก็น่าจะรู้เรื่องนี้กันดีว่าการดัก Event โดยตรงนั้นทำไม่ได้ แต่ทว่าในความเป็นจริงนักพัฒนาสามารถเขียนโค้ดเพื่อดักการทำงานแบบทางอ้อมได้ครับ

        "ไม่รู้ว่ากด Screenshot เมื่อไร แต่รู้ว่าเมื่อกด Screenshot จะมีไฟล์ภาพถูกบันทึกลงในเครื่อง" 

        นี่คือวิธีที่เจ้าของบล็อกจะใช้เพื่อดักว่าผู้ใช้ทำการกด Screenshot นั่นเอง และถ้าไม่คิดอะไรมากนัก ก็จะนึกถึงคลาสที่ชื่อว่า FileObserver ที่เอาไว้ดักว่ามีการสร้างไฟล์ในเครื่องหรือไม่ พอคิดแบบนี้แล้ว ก็ไม่น่าจะยากซักเท่าไรเนอะ

        แต่ปัญหาของการใช้ FileObserver ก็คือ "Path ที่เก็บภาพ Screenshot อยู่ที่ไหนในเครื่องล่ะ?" ผู้ที่หลงเข้ามาอ่านอาจจะเข้าใจว่าไฟล์ดังกล่าวถูกเก็บไว้ใน /Pictures/Screenshots ทุกครั้ง

        ซึ่งนั่นเป็นความเข้าใจที่ผิดครับ เพราะว่าไม่ใช่ทุกรุ่นทุกยี่ห้อที่จะเก็บภาพ Screenshot ไว้ที่นั่น ยกตัวอย่างเช่น Samsung ในรุ่นหลังๆที่เก็บไฟล์ไว้ที่ /DCIM/Screenshots แทน ดังนั้นการมานั่งหา Path ของแต่ละเครื่องก็คงไม่ใช่เรื่องสนุกซักเท่าไร

        แล้วควรจะใช้วิธีไหนล่ะ?

ดักการ Screenshot จาก Content Provider

        Content Provider มีหน้าที่ควบคุมข้อมูลภายในเครื่องอยู่แล้ว โดยที่ตัวมันสามารถรู้ได้ทันทีว่ามีไฟล์ถูกสร้างขึ้นมาจากการ Screenshot และ Content Provider ก็เปิดให้นักพัฒนาสามารถดัก Event ที่ว่าได้ผ่านคลาสที่ชื่อว่า ContentObserver 

        ดังนั้นเราจะต้องดักไฟล์ภาพ Screenshot จาก Content Provider โดยใช้ Content Observer นั่นเอง

มาเริ่มกันเถอะ!

        โดยปกติแล้ว Activity ที่นักพัฒนาเรียกใช้งานกันอยู่ทุกวันนั้นมีคำสั่งสำหรับ Content Observer ให้อยู่แล้วนะ

// Register Content Observer
getContentResolver().registerContentObserver(uri, notifyForDescendants, contentObserver);

// Unregister Content Observer
getContentResolver().unregisterContentObserver(contentObserver);

        อยากจะให้ Content Observer ทำงานก็ใช้คำสั่ง Register ซะ และถ้าใช้งานเสร็จแล้วก็ควรจะ Unregister ทิ้งด้วย ซึ่งเจ้าของบล็อกแนะนำให้เรียกคำสั่ง Register ใน onStart() และ Unregister ใน onStop() ครับ

        ทีนี้มาดูกันต่อที่คำสั่ง registerContentObserver(...) กันว่ามีอะไรที่จะต้องกำหนดบ้าง

Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
boolean notifyForDescendants = true;
ContentObserver contentObserver = ...
getContentResolver().registerContentObserver(uri, notifyForDescendants, contentObserver);

        uri คือ Path ของ Directory ที่ต้องการให้ Content Observer คอยเช็คว่ามีการเปลี่ยนแปลงของไฟล์หรือไม่ ซึ่งในที่นี้ให้กำหนดเป็น

MediaStore.Images.Media.EXTERNAL_CONTENT_URI

        ซึ่งหมายถึงไฟล์ภาพที่อยู่ใน External Storage นั่นเอง

        notifyForDescendants คือกำหนดว่าจะให้ Content Observer เช็คแค่ Directory นั้นๆโดยตรงหรือว่าจะให้เช็ค Directory ย่อยที่อยู่ในนั้นด้วย ก็ให้กำหนดเป็น True ไป (เพราะไม่รู้ว่าไฟล์ภาพ Screenshot นั้นอยู่ที่ไหน)

        contentObserver คือตัว Content Observer ที่เจ้าของบล็อกจะใช้เพื่อเช็คว่ามีไฟล์ถูกสร้างขึ้นมาใหม่ตอนไหน และไฟล์นั้นเป็นไฟล์ภาพ Screenshot หรือป่าว

สร้าง Content Observer

        การสร้างคลาส Content Observer ขึ้นมาใช้งานจะเป็นแบบนี้เลย

private ContentObserver contentObserver = new ContentObserver(new Handler()) {
    @Override
    public boolean deliverSelfNotifications() {
        return super.deliverSelfNotifications();
    }

    @Override
    public void onChange(boolean selfChange) {
        super.onChange(selfChange);
    }

    @Override
    public void onChange(boolean selfChange, Uri uri) {
        super.onChange(selfChange, uri);
        // TODO Do something
    }
};

        โดยจะต้องกำหนด Handler เข้าไปด้วย ซึ่งเจ้าของบล็อกก็ใช้วิธีสร้าง Handler ขึ้นมาใหม่ ณ ตอนนั้นเลย แล้ว Content Observer จะบังคับให้ Implement Method ทั้ง 3 ตัวด้วยกัน แต่ที่ต้องสนใจจะมีแค่ตัวเดียวคือ

onChange(boolean selfChange, Uri uri)

        ให้สังเกตที่ onChange(...) ดีๆครับจะเห็นว่ามันเป็น Overload Method ซึ่งแบบแรกจะส่งแค่ Boolean มาบอก ซึ่งเป็นของ API 1 ส่วนที่เรียกใช้งานจริงๆนั้นจะเป็นแบบที่ส่งมาทั้ง Boolean และ Uri ซึ่งเป็นของ API 16 ดังนั้นจึงหมายความว่าวิธีที่ใช้ในบทความนี้จะใช้ได้กับ API 16 ขึ้นไปเท่านั้นนะ

แปลง Uri ให้กลายเป็น Path ของไฟล์ภาพที่อยู่ในเครื่อง

       โดย Uri ที่ส่งมาให้ใน onChange(...) นั้นก็คือ Uri ของไฟล์ที่มีการเปลี่ยนแปลง โดยค่าที่ได้จะมีลักษณะแบบนี้

content://media/external/images/media/80762

        ทว่า Path ดังกล่าวไม่ใช่ Path จริงที่อยู่ในเครื่อง เพราะมันเป็น Path ที่อยู่ใน Content Provider ซึ่งยังเอาไปใช้งานเลยไม่ได้ ดังนั้นจะต้องเรียกใช้ ContentResolver เพื่อหาว่า Path จริงๆนั้นคืออะไร โดยใช้คำสั่ง

private String getFilePathFromContentResolver(Context context, Uri uri) {
    try {
        Cursor cursor = context.getContentResolver().query(uri, new String[]{
                MediaStore.Images.Media.DISPLAY_NAME,
                MediaStore.Images.Media.DATA
        }, null, null, null);
        if (cursor != null && cursor.moveToFirst()) {
            String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
            cursor.close();
            return path;
        }
    } catch (IllegalStateException ignored) {
    }
    return null;
}

         ซึ่งจะได้ผลลัพธ์ออกมาเป็น Path จริงๆที่อยู่ในเครื่อง

/storage/emulated/0/DCIM/Screenshots/Screenshot_20171017-010002.png

        เพิ่มเติม : แต่เนื่องจากคำสั่งดังกล่าวจะต้องกำหนด Permission เพื่ออ่านข้อมูลใน External Storage ไว้ด้วย

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

        และ Permission ตัวนี้ต้องเขียน Runtime Permission ไว้ด้วย เพราะไม่เช่นนั้นจะเจอกับ Error เมื่อทดสอบบน API 23 ขึ้นไปแบบนี้

FATAL EXCEPTION: main
Process: com.akexorcist.screenshotdetection, PID: 2129
java.lang.SecurityException: Permission Denial: reading com.android.providers.media.MediaProvider uri content://media/external/images/media/80766 from pid=2129, uid=10281 requires android.permission.READ_EXTERNAL_STORAGE, or grantUriPermission()
    at android.os.Parcel.readException(Parcel.java:1684)
    at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:183)
    at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:135)
    at android.content.ContentProviderProxy.query(ContentProviderNative.java:421)
    at android.content.ContentResolver.query(ContentResolver.java:532)
    at android.content.ContentResolver.query(ContentResolver.java:474)
    at com.akexorcist.screenshotdetection.MainActivity.getFilePathFromContentResolver(MainActivity.java:55)
    at com.akexorcist.screenshotdetection.MainActivity.access$000(MainActivity.java:13)
    at com.akexorcist.screenshotdetection.MainActivity$1.onChange(MainActivity.java:43)
    at android.database.ContentObserver.onChange(ContentObserver.java:145)
    at android.database.ContentObserver$NotificationRunnable.run(ContentObserver.java:216)
    at android.os.Handler.handleCallback(Handler.java:751)
    at android.os.Handler.dispatchMessage(Handler.java:95)
    at android.os.Looper.loop(Looper.java:154)
    at android.app.ActivityThread.main(ActivityThread.java:6165)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:888)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:778)

        ซึ่งเจ้าของบล็อกขอข้ามโค้ดส่วนนี้ไป แต่ผู้ที่หลงเข้ามาอ่านต้องไปหาต่อเองนะว่าเขียนยังไง

เช็คว่าเป็นไฟล์ภาพของ Screenshot หรือไม่

         เจ้าของบล็อกจะใช้วิธีเช็คอย่างง่ายด้วยเงื่อนไขว่า Path ของไฟล์ภาพนั้นจะต้องมีคำว่า "screenshots" อยู่ข้างใน

private boolean isScreenshotPath(String path) {
    return path != null && path.toLowerCase().contains("screenshots");
}


รวมคำสั่งทั้งหมดไว้ใน onChange(boolean selfChange, Uri uri) 

        เวลาเรียกใช้คำสั่ง getFilePathFromContentResolver(Context context, Uri uri) และ isScreenshotPath(String path) ก็จะเป็นแบบนี้

private ContentObserver contentObserver = new ContentObserver(new Handler()) {
    ...

    @Override
    public void onChange(boolean selfChange, Uri uri) {
        super.onChange(selfChange, uri);
        String path = getFilePathFromContentResolver(getApplicationContext(), uri);
        if (isScreenshotPath(path)) {
            onScreenCaptured(path);
        }
    }
};

private void onScreenCaptured(String path) {
    // TODO Do something
}

private boolean isScreenshotPath(String path) {
    ...
}

private String getFilePathFromContentResolver(Context context, Uri uri) {
    ...
}

        โดยคำสั่งใน onScreenCaptured(String path) มีไว้ให้ผู้ที่หลงเข้ามาอ่านใส่คำสั่งเองเลย ว่าอยากจะให้ทำอะไรเมื่อผู้ใช้กด Screenshot


สรุป

        ผู้ที่หลงเข้ามาอ่านสามารถรู้ได้ว่าผู้ใช้กด Screenshot ได้นะ แต่ไม่ได้เรียกใช้คำสั่งโดยตรง เพราะต้องใช้วิธีทางอ้อมด้วยการเช็คจาก Content Provider แทน ซึ่งคำสั่งที่ใช้จะรองรับกับ API 16 ขึ้นไป

        และเนื่องจากโค้ดดังกล่าวนี้จะถูกเรียกใช้งานซ้ำซ้อนถ้าต้องใช้กับหลายๆ Activity ดังนั้นเพื่อให้โค้ดกระชับและเรียกใช้งานได้ง่ายขึ้น เจ้าของบล็อกจึงทำเป็น Library ซะเลย สามารถดูรายละเอียดได้ที่ Screenshot Detection [GitHub]

แหล่งข้อมูลอ้างอิง

        • Detect only screenshot with FileObserver Android [Stackoverflow]