diff --git a/android/app/src/main/java/com/expensify/chat/MainActivity.kt b/android/app/src/main/java/com/expensify/chat/MainActivity.kt index c1fc3c15b1e4..91ce8a396922 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -2,12 +2,14 @@ package com.expensify.chat import android.content.Intent import android.content.pm.ActivityInfo +import android.content.res.Resources import android.os.Bundle import android.util.Log import android.view.KeyEvent import android.view.View import android.view.WindowInsets import com.expensify.chat.bootsplash.BootSplash +import com.expensify.chat.bootsplash.PrivacyCover import com.expensify.chat.intenthandler.IntentHandlerFactory import com.expensify.reactnativekeycommand.KeyCommandModule import com.facebook.react.ReactActivity @@ -121,4 +123,66 @@ class MainActivity : ReactActivity() { override fun onStart() { super.onStart() } + + // It lets us tell "leaving the app" from "internal window", i.e. a window we own that + // takes focus on top of this activity (Dialog, PopupWindow, IME) while we stay top resumed. + private var isTopResumed = true + + // It lets us tell "leaving the app" from "another activity we opened ourselves" (e.g. Plaid) + private var startedActivityOurselves = false + + // Deprecated to steer callers to registerForActivityResult. Overriding it is safe: startActivity() and registerForActivityResult still go through it + @Deprecated("Deprecated in Java") + override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { + startedActivityOurselves = true + try { + super.startActivityForResult(intent, requestCode, options) + } catch (e: RuntimeException) { + startedActivityOurselves = false + throw e + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + startedActivityOurselves = false + hidePrivacyDialog() + } else if (!isTopResumed) { + showPrivacyDialog() + } + } + + override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) { + super.onTopResumedActivityChanged(isTopResumedActivity) + isTopResumed = isTopResumedActivity + if (isTopResumedActivity) { + startedActivityOurselves = false + hidePrivacyDialog() + } else if (!hasWindowFocus()) { + // Backgrounding with an in-app dialog open: this callback is the only "leaving the app" signal. + showPrivacyDialog() + } + } + + override fun onApplyThemeResource(theme: Resources.Theme, resid: Int, first: Boolean) { + super.onApplyThemeResource(theme, resid, first) + PrivacyCover.applyTaskDescription(this) + } + + override fun onDestroy() { + hidePrivacyDialog() + super.onDestroy() + } + + private fun showPrivacyDialog() { + if (startedActivityOurselves) { + return + } + PrivacyCover.show(this) + } + + private fun hidePrivacyDialog() { + PrivacyCover.hide() + } } diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.kt b/android/app/src/main/java/com/expensify/chat/MainApplication.kt index 89c47fdfa86d..033008cf8a02 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -8,10 +8,12 @@ import android.app.Application import android.content.pm.ActivityInfo import android.content.res.Configuration import android.database.CursorWindow +import android.os.Build import android.os.Bundle import android.os.Process import androidx.multidex.MultiDexApplication import com.expensify.chat.bootsplash.BootSplashPackage +import com.expensify.chat.bootsplash.PrivacyCover import com.expensify.chat.navbar.NavBarManagerPackage import com.expensify.chat.shortcutManagerModule.ShortcutManagerPackage import com.margelo.nitro.nitrofetch.AutoPrefetcher @@ -53,6 +55,14 @@ class MainApplication : MultiDexApplication(), ReactApplication { // here so Plaid can render in whichever orientation the device is actually in. registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.setRecentsScreenshotEnabled(false) + // Third-party activities (e.g. Plaid) don't set our TaskDescription, so their recents fallback card would be black. + // They don't get the cover dialog: their focus loss can't be told apart from their own popups or translucent activities. + if (!activity.javaClass.name.startsWith("com.expensify.")) { + PrivacyCover.applyTaskDescription(activity) + } + } if (activity.javaClass.name == "com.plaid.internal.link.LinkActivity") { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } diff --git a/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java b/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java new file mode 100644 index 000000000000..b00190b4ab14 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java @@ -0,0 +1,59 @@ +package com.expensify.chat.bootsplash; + +import android.app.Activity; +import android.app.ActivityManager; +import android.os.Build; +import android.view.Window; +import android.view.WindowManager; +import com.expensify.chat.R; + +// Covers the UI with the boot splash while the app is leaving the foreground +public final class PrivacyCover { + + // Only MainActivity shows the cover + private static BootSplashDialog cover; + + private PrivacyCover() {} + + // setRecentsScreenshotEnabled() requires API 33, so the cover alone wouldn't suppress the snapshot + public static boolean isSupported() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU; + } + + public static void show(Activity activity) { + if (!isSupported() || activity.isFinishing() || activity.isDestroyed() + || activity.isInMultiWindowMode() || cover != null) { + return; + } + BootSplashDialog dialog = new BootSplashDialog(activity, R.style.BootTheme); + Window window = dialog.getWindow(); + if (window != null) { + // Leave the focus with the activity underneath so it returns exactly as the user left it + window.setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + // The default dialog fade-in would let the snapshot see the content underneath + window.setWindowAnimations(0); + } + dialog.show(); + cover = dialog; + } + + public static void hide() { + if (cover != null) { + cover.dismiss(); + cover = null; + } + } + + // Recents shows this card instead of a snapshot, since snapshots are disabled for privacy. + public static void applyTaskDescription(Activity activity) { + if (!isSupported()) { + return; + } + activity.setTaskDescription(new ActivityManager.TaskDescription.Builder() + .setBackgroundColor(activity.getColor(R.color.bootsplash_background)) + .setStatusBarColor(activity.getColor(R.color.bootsplash_background)) + .build()); + } +} diff --git a/cspell.json b/cspell.json index ab05306535c1..d43d392bc956 100644 --- a/cspell.json +++ b/cspell.json @@ -27,6 +27,7 @@ "AUTOREIMBURSED", "AUTOREPORTING", "AVURL", + "Backgrounding", "Accelo", "Addendums", "Aeroplan", @@ -409,6 +410,7 @@ "Subprocessors", "Subviews", "Supercenters", + "Superview", "Svenska", "Svmy", "Swedbank", @@ -976,6 +978,7 @@ "removeHiddenElems", "requestee", "reserialization", + "resid", "resizeable", "resultsbox", "retryable", diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 6cb01988380b..14bd2fe3a401 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -44,6 +44,9 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate { var window: UIWindow? var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory? + private var privacyOverlay: UIView? + private weak var responderBeforePrivacyOverlay: UIResponder? + private var selectedTextRangeBeforePrivacyOverlay: UITextRange? /// Read back by `SceneDelegate`, which starts React Native once a scene exists. var launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -102,6 +105,70 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate { return true } + // Cover the UI before the OS captures the app-switcher snapshot, so sensitive data never ends up on disk. + // Called by `SceneDelegate`, UIKit doesn't send the app-level active/resign callbacks under the scene lifecycle. + func showPrivacyOverlay() { + guard privacyOverlay == nil, let window else { + return + } + + // Store the first responder and its selected text range, then dismiss the keyboard + let focusedResponder = findFirstResponder(in: window) + responderBeforePrivacyOverlay = focusedResponder + selectedTextRangeBeforePrivacyOverlay = (focusedResponder as? UITextInput)?.selectedTextRange + window.endEditing(true) + + guard + let overlay = UIStoryboard(name: "BootSplash", bundle: nil) + .instantiateInitialViewController()?.view + else { + return + } + overlay.frame = window.bounds + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(overlay) + privacyOverlay = overlay + } + + func hidePrivacyOverlay() { + privacyOverlay?.removeFromSuperview() + privacyOverlay = nil + restoreResponderBeforePrivacyOverlay() + } + + // Give focus (and the caret position) back to whatever showPrivacyOverlay dismissed + private func restoreResponderBeforePrivacyOverlay() { + let responder = responderBeforePrivacyOverlay + let selectedTextRange = selectedTextRangeBeforePrivacyOverlay + responderBeforePrivacyOverlay = nil + selectedTextRangeBeforePrivacyOverlay = nil + + // The view can be gone (or detached) by the time we come back + guard let responder, responder.canBecomeFirstResponder else { + return + } + if let view = responder as? UIView, view.window == nil { + return + } + guard responder.becomeFirstResponder() else { + return + } + if let selectedTextRange, let textInput = responder as? UITextInput { + textInput.selectedTextRange = selectedTextRange + } + } + + private func findFirstResponder(in view: UIView) -> UIResponder? { + if view.isFirstResponder { + return view + } + for subview in view.subviews { + if let responder = findFirstResponder(in: subview) { + return responder + } + } + return nil + } override func applicationWillTerminate(_ application: UIApplication) { if #available(iOS 16.2, *) { diff --git a/ios/SceneDelegate.swift b/ios/SceneDelegate.swift index fc856686a702..fea87afd2683 100644 --- a/ios/SceneDelegate.swift +++ b/ios/SceneDelegate.swift @@ -71,9 +71,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { forward(userActivity: userActivity) } - // There is no scene lifecycle forwarding because nothing implements the matching - // `UIApplicationDelegate` methods. RN's `AppState` and every SDK here (Airship, Firebase, - // SDWebImage) observe the `UIApplication` notifications, which UIKit posts either way. + func sceneWillResignActive(_ scene: UIScene) { + appDelegate?.showPrivacyOverlay() + } + + func sceneDidBecomeActive(_ scene: UIScene) { + appDelegate?.hidePrivacyOverlay() + } + + // The privacy overlay is the only scene lifecycle forwarding. RN's `AppState` and every SDK here + // (Airship, Firebase, SDWebImage) observe the `UIApplication` notifications, which UIKit posts either way. // MARK: - Launch options