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

val activity: Activity = /* ... */

// Register ContentObserver 
activity.contentResolver.registerContentObserver(/* ... */)

// Unregister ContentObserver
activity.contentResolver.unregisterContentObserver(/* ... */)

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

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

val activity: Activity = /* ... */
val uri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val notifyForDescendants: Boolean = true
val contentObserver: ContentObserver = /* ... */
activity.contentResolver.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 ขึ้นมาใช้งานจะเป็นแบบนี้เลย

val contentObserver: ContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
    override fun onChange(selfChange: Boolean, uri: Uri?) {
        /* ... */
    }
}

โดยจะต้องกำหนด Handler เข้าไปด้วย ซึ่งเจ้าของบล็อกก็ใช้วิธีสร้าง Handler ขึ้นมาจาก Main Looper เลย

จริงๆแล้ว Content Observer มี Override Method อยู่หลายตัว แต่ที่ต้องสนใจจริงๆจะมีแค่ตัวเดียวคือ

override fun onChange(selfChange: Boolean, uri: Uri)

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

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

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

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

private fun getFilePathFromContentResolver(context: Context, uri: Uri): String? {
        try {
            context.contentResolver.query(
                uri,
                arrayOf(
                    MediaStore.Images.Media.DISPLAY_NAME,
                    MediaStore.Images.Media.DATA
                ),
                null,
                null,
                null
            )?.let { cursor ->
                cursor.moveToFirst()
                val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA))
                cursor.close()
                return path
            }
        } catch (ignored: IllegalStateException) { 
        }
        return null
    }

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

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

แต่เนื่องจากคำสั่งดังกล่าวจะต้องกำหนด Permission เพื่ออ่านข้อมูลใน External Storage ไว้ด้วย ถ้าไม่ได้ขอ Permission ไว้ ตอนทดสอบบน API 23 ก็จะเจอ Error Log แบบนี้

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)

โดยเจ้าของบล็อกจะข้ามโค้ดส่วนนี้ไป เพราะเป็นเรื่องของ Runtime Permission ไม่ได้เกี่ยวกับบทความนี้โดยตรง

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

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

private fun isScreenshotPath(path: String?): Boolean {
    return path != null && path.toLowerCase(Locale.getDefault()).contains("screenshot")
}

รวมคำสั่งทั้งหมดเข้าด้วยกัน

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

val context: Context = /* ... */
val contentObserver: ContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
    override fun onChange(selfChange: Boolean, uri: Uri?) {
        val path = getFilePathFromContentResolver(context, uri)
        if(isScreenshotPath(path)) {
            onScreenCaptured(path)
        }
    }

    private fun isScreenshotPath(path: String?): Boolean { /* ... */ }
    private fun getFilePathFromContentResolver(context: Context, uri: Uri): String? { /* ... */ }
    
    private fun onScreenCaptured(path: String) {
        // Do something
    }
}

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

ควรใส่ Debounce ไว้ด้วย

เพราะว่าบนแอนดรอยด์บางรุ่นหรือบางเวอร์ชันจะเกิด onChange(...) ได้มากกว่า 1 ครั้ง จากการกด Screenshot เพียงแค่ครั้งเดียว ดังนั้นทางที่ดีควรทำ Debounce เผื่อไว้ด้วย เพื่อไม่ได้ Event ส่งมาซ้ำซ้อนกัน

จะใช้ ReactiveX หรือ Kotlin Flow ก็ได้

สรุป

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

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

สามารถดูรายละเอียดของ Library ได้ที่ Screenshot Detection - Akexorcist [GitHub]

akexorcist/ScreenshotDetection
[Android] Screenshot detection while user using your app - akexorcist/ScreenshotDetection

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