Multitasking Intrusion and Preventing Screenshots in Android Apps
Tomáš Repčík on 2023-12-08
Multitasking Intrusion and Preventing Screenshots in Android Apps
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.
Here are the various methods, which I am going to describe with pros, cons and some ideas:
- FLAG_SECURE
- setRecentsScreenshotEnabled
- onWindowFocusChanged with FLAG_SECURE / custom view
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 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
- Set
FLAG_SECURE
inonCreate
in one root activity - Separate sensitive content to individual activities and just set
FLAG_SECURE
inonCreate
- Turn on the flag based on the context. If the sensitive information is visible, add the flag and remove it if it is not needed anymore
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.
- Some phones do not obscure the multitask preview right away when the user moves the app to the background. The user must switch to another app and then the preview is obscured.
setRecentsScreenshotEnabled
is available from SDK 33- Cannot rely on the lifecycle of the activity to use flags/methods
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.:
- User is asked for permission
- User drags down the notification bar
- User moves to multitask 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 the 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, then 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:
- Use
FLAG_SECURE
, if possible - it protects against taking screenshots, videos and obscures previews at the same time - Implement login/PIN/biometry and test it properly
- Use/keep the private information of the user only if it is needed/desired from the use case. If you have nothing to hide, you do not have to worry about hiding stuff!
More from Android development:
Android development Posts about development in Android.tomas-repcik.medium.com