ไม่นานมานี้ เจ้าของบล็อกได้สังเกตเห็นว่า 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 ไว้ด้วย

และ 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]