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