Tomáš Repčík - 9. 12. 2023

Multitasking Intrusion and Preventing Screenshots in Android App

Protect your user’s privacy and adhere to possible technical requirements

The security of the user should be among the first things to tackle in every app, which manipulates with user’s data. In terms of finance apps, state services, healthcare and others, the developers should pay extra attention.

One of the technical requirements, which pops up usually is preventing users from taking screenshots or obscuring the multitasking preview of the app. I have gone through many paths and I have found it quite challenging to find a solution for obscuring the multitask preview while allowing users to take screenshots of the screen in Android.

List of methods, which I am going to describe with pros, cons and some ideas

FLAG_SECURE

To prevent users from taking screenshots and obscuring their multitask preview, there is a simple flag FLAG_SECURE for it:

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        window.setFlags(
            WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE
        )
        
        setContent {
            // compose content
        }
    }
}

You can set FLAG_SECURE in onCreate method and activity will be protected from taking screenshots and the preview in the recent apps will be obscured.

Dialogs and other popups have their own windows object and flag needs to be set upon their creation too.

FLAG_SECURE and lifecycle changes

Unfortunately, if you try to add FLAG_SECURE flag in onPause call, the app will not get obscured in the multitask preview and screenshots can be taken. It is because that the preview is created already before the flag takes effect. In the end, the flag is ignored.

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // compose content
        }
    }

    override fun onPause() {
        window.addFlags(
            WindowManager.LayoutParams.FLAG_SECURE
        )
        super.onPause()

    }

    override fun onResume() {
        super.onResume()
        window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
    }

}

However, this restriction does not apply to scenarios, where you apply the flag when the user actively uses the app. If the button is added, which adds and clears the flag, the functionality works as expected.

val flag = remember {
    mutableStateOf(false)
}
Button(onClick = {
    if (flag.value) {
        window.clearFlags(
            WindowManager.LayoutParams.FLAG_SECURE
        )
    } else {
        window.addFlags(
            WindowManager.LayoutParams.FLAG_SECURE
        )
    }
    flag.value = flag.value.not()
}) {
    Text(text = "Secure flag: ${flag.value}")
}

Ideas on how to use FLAG_SECURE

Be aware that forbidding screenshots can result in harder troubleshooting. For the development team it is important to have version of the app, where the flag is missed out intentionally. In production, the flag should be presented but more advanced crashlytics and logging methods must be implemented to avoid awkward situations.

setRecentsScreenshotEnabled()

From Android 13 / SDK 33, Activity supports a new function called setRecentsScreenshotEnabled. This method prevents users from taking screenshots of the multitask preview of the app and obscures it without calling any flag of the screen. By default, the activity is set to true, but to disable screenshots of the preview, the app needs to set it to false.

User can still take screenshot of the app during active use.

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setRecentsScreenshotEnabled(false)
        setContent {
            // content
        }
    }

}

This is quite a simple and elegant solution, but it does not work with devices with a lower SDK than 33. Otherwise, it should be used as FLAG_SECURE in similar scenarios.

Issues with the methods above

The methods above are not perfect and that is why I dug deeper and found/created other ways to approach this topic. Here is a brief list of the most common issues.

Following methods can be more clumsy / can work differently on various phones, but I think some people will find them useful and worth the try.

onWindowFocusChanged with FLAG_SECURE / dialog

onWindowFocusChanged is provided by the activity as a method, which can be overridden. The method is called when the user directly interacts with the activity. The activity loses focus e.g.:

The advantage of this method is, that it is called before the activity creates the preview, so we can apply the methods above to obscure the preview / disable screenshots of the preview.

FLAG_SECURE version

For example, we can add FLAG_SECURE flag based on this trigger.

class MainActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // content
        }
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus) {
            window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
        } else {
            window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
        }
    }
}

Custom view version

At this stage, the UI can still be customized. We can change the screen or overlay of our app by which the contents get obscured. For demonstration, the example will use the dialog, but feel free to use any other UI component. The dialog has an advantage, that you do not need to change anything UI-related underneath it.

class MainActivity : ComponentActivity() {

    // placeholder for dialog
    private var dialog: Dialog? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // UI contents
        }
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus) {
            dialog?.dismiss()
            dialog = null
        } else {
            // style to make it full screen
            Dialog(this, android.R.style.Theme_Black_NoTitleBar_Fullscreen).also { dialog ->
                this.dialog = dialog
                // must be no focusable, so if the user comes back, the activity underneath it gets the focus
                dialog.window?.setFlags(
                    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                )
                // your custom UI
                dialog.setContentView(R.layout.activity_obscure_layout)
                dialog.show()
            }
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<!-- R.layout.activity_obscure_layout -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/obscure_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/black" />

The dialog needs to use full screen style, to occupy full size.

Following the dialog creation, the dialog cannot obtain focus by adding FLAG_NOT_FOCUSABLE. The reason is, if the user comes back to the app the activity will not get focused and the method will not get called, because the dialog will get focused. By adding this flag, the focus falls on the view underneath the dialog. The view behind the dialog is our activity, so the method gets triggered and dialog is dismissed.

Afterwards, the dialog can inflate any UI.

The issue with this approach is that every time the user is asked for permission or goes to check notifications, than this dialog appears. Some scoping for the permission is possible, but it is impossible to determine when the notification bar is pulled down. In the most cases the notifications occupies whole screen, but it is not guaranteed that the user will not see the custom UI to obscure the activity.

Not working implementations

I tried to achieve a similar effect via the lifecycle of the activity, but to no avail, unfortunately.

In this code snippet, I tried to replace the composable with empty composable, but when the onPause is called, it is already too late to change the screen. This will result in a multitask preview of proper UI.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            var isObscured by remember { mutableStateOf(false) }
            val lifecycleOwner = LocalLifecycleOwner.current
            val lifecycleObserver = LifecycleEventObserver { _, event ->
                when (event) {
                    Lifecycle.Event.ON_RESUME -> isObscured = false
                    Lifecycle.Event.ON_PAUSE -> isObscured = true
                    else -> Unit
                }
            }
            DisposableEffect(key1 = lifecycleOwner) {
                onDispose {
                    lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
                }
            }
            lifecycleOwner.lifecycle.addObserver(lifecycleObserver)

            if (isObscured) {
                Surface {}
            } else {
                SecuredContent(text = "Composable lifecycle")
            }
        }
    }
}

The same goes for this code snippet, when I try to inflate XML view on top of the activity. The solution falls short because of the same problem as the code snippet above.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_composable_layout)
        findViewById<ComposeView>(R.id.main_composable).setContent {
            SecuredContent(text = "Pause and Resume with XML layout")
        }
    }

    override fun onPause() {
        val mainComposable = findViewById<RelativeLayout>(R.id.main_container)
        val obscureView = layoutInflater.inflate(R.layout.activity_obscure_layout, null)
        mainComposable.addView(obscureView, mainComposable.width, mainComposable.height)
        super.onPause()

    }

    override fun onResume() {
        super.onResume()
        val mainComposable = findViewById<RelativeLayout>(R.id.main_container)
        mainComposable.removeView(findViewById<ComposeView>(R.id.obscure_layout))
    }
}

Conclusion

Some last recommendations, what I would do:

Thanks for reading and do not forget to subscribe for more!

Subscribe for more
LinkedIn GitHub Medium