From ebb1e5f01b046373d7bba2d975d7256fff52e00b Mon Sep 17 00:00:00 2001 From: Olgierd Date: Tue, 8 Sep 2026 13:36:05 +0200 Subject: [PATCH 1/6] Cover the UI with the boot splash when the app is backgrounded to prevent sensitive data exposure --- .../java/com/expensify/chat/MainActivity.kt | 75 ++++++++++++++++++ .../com/expensify/chat/MainApplication.kt | 8 ++ cspell.json | 3 + ios/AppDelegate.swift | 76 +++++++++++++++++++ 4 files changed, 162 insertions(+) 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..aea428672af1 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -1,13 +1,19 @@ package com.expensify.chat +import android.app.Activity +import android.app.ActivityManager import android.content.Intent import android.content.pm.ActivityInfo +import android.content.res.Resources +import android.os.Build import android.os.Bundle import android.util.Log import android.view.KeyEvent import android.view.View import android.view.WindowInsets +import android.view.WindowManager import com.expensify.chat.bootsplash.BootSplash +import com.expensify.chat.bootsplash.BootSplashDialog import com.expensify.chat.intenthandler.IntentHandlerFactory import com.expensify.reactnativekeycommand.KeyCommandModule import com.facebook.react.ReactActivity @@ -19,6 +25,19 @@ import expo.modules.ReactActivityDelegateWrapper class MainActivity : ReactActivity() { companion object { private const val APP_START_TIME_PREFERENCES = "AppStartTime" + + // Recents shows this card instead of a snapshot, since snapshots are disabled for privacy. + fun applyTaskDescription(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + activity.setTaskDescription( + ActivityManager.TaskDescription.Builder() + .setBackgroundColor(activity.getColor(R.color.bootsplash_background)) + .setStatusBarColor(activity.getColor(R.color.bootsplash_background)) + .build() + ) + } } /** @@ -121,4 +140,60 @@ class MainActivity : ReactActivity() { override fun onStart() { super.onStart() } + + // It lets us tell "leaving the app" from "internal window" + private var isTopResumed = true + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + hidePrivacyDialog() + } else if (!isTopResumed) { + showPrivacyDialog() + } + } + + override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) { + super.onTopResumedActivityChanged(isTopResumedActivity) + isTopResumed = isTopResumedActivity + if (isTopResumedActivity) { + 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) + applyTaskDescription(this) + } + + override fun onDestroy() { + hidePrivacyDialog() + super.onDestroy() + } + + private var privacyDialog: BootSplashDialog? = null + + private fun showPrivacyDialog() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || privacyDialog != null || isFinishing || isDestroyed) { + return + } + val dialog = BootSplashDialog(this, R.style.BootTheme) + // When the user comes back the activity underneath regains focus + dialog.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 + dialog.window?.setWindowAnimations(0) + dialog.show() + privacyDialog = dialog + } + + private fun hidePrivacyDialog() { + privacyDialog?.dismiss() + privacyDialog = null + } } 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..520613d012e3 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -8,6 +8,7 @@ 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 @@ -53,6 +54,13 @@ 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. + if (!activity.javaClass.name.startsWith("com.expensify.")) { + MainActivity.applyTaskDescription(activity) + } + } if (activity.javaClass.name == "com.plaid.internal.link.LinkActivity") { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } diff --git a/cspell.json b/cspell.json index e2fbde6758f4..baf1e49d453f 100644 --- a/cspell.json +++ b/cspell.json @@ -26,6 +26,7 @@ "AUTOREIMBURSED", "AUTOREPORTING", "AVURL", + "Backgrounding", "Accelo", "Addendums", "Aeroplan", @@ -390,6 +391,7 @@ "Subprocessors", "Subviews", "Supercenters", + "Superview", "Svenska", "Svmy", "Swedbank", @@ -939,6 +941,7 @@ "remotesync", "removeHiddenElems", "requestee", + "resid", "resizeable", "resultsbox", "retryable", diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 8c477158546b..f58bdd35890a 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -45,6 +45,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? override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { // Initialize Sentry before any native telemetry (e.g. certificate pinning monitor reports). @@ -106,6 +109,79 @@ 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. + override func applicationWillResignActive(_ application: UIApplication) { + super.applicationWillResignActive(application) + showPrivacyOverlay() + } + + override func applicationDidBecomeActive(_ application: UIApplication) { + hidePrivacyOverlay() + super.applicationDidBecomeActive(application) + } + + private 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 + } + + private 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, *) { From 4bccaf63e481bfd41ecc1e8b66beb408c98bdfe7 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 15 Oct 2026 20:18:42 +0200 Subject: [PATCH 2/6] Move privacy overlay to SceneDelegate lifecycle --- ios/AppDelegate.swift | 15 +++------------ ios/SceneDelegate.swift | 13 ++++++++++--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 9a7f42b78a07..14bd2fe3a401 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -106,17 +106,8 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate { } // Cover the UI before the OS captures the app-switcher snapshot, so sensitive data never ends up on disk. - override func applicationWillResignActive(_ application: UIApplication) { - super.applicationWillResignActive(application) - showPrivacyOverlay() - } - - override func applicationDidBecomeActive(_ application: UIApplication) { - hidePrivacyOverlay() - super.applicationDidBecomeActive(application) - } - - private func showPrivacyOverlay() { + // 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 } @@ -139,7 +130,7 @@ class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate { privacyOverlay = overlay } - private func hidePrivacyOverlay() { + func hidePrivacyOverlay() { privacyOverlay?.removeFromSuperview() privacyOverlay = nil restoreResponderBeforePrivacyOverlay() 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 From 87737c08a1a7b5e4ba351ee0962fa9c84f343553 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 15 Oct 2026 21:18:16 +0200 Subject: [PATCH 3/6] Cover third-party activities and skip cover when starting our own --- .../java/com/expensify/chat/MainActivity.kt | 59 ++++++-------- .../com/expensify/chat/MainApplication.kt | 8 +- .../chat/bootsplash/PrivacyCover.java | 78 +++++++++++++++++++ android/app/src/main/res/values/colors.xml | 1 + 4 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java 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 aea428672af1..520f048652af 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -1,19 +1,15 @@ package com.expensify.chat -import android.app.Activity -import android.app.ActivityManager import android.content.Intent import android.content.pm.ActivityInfo import android.content.res.Resources -import android.os.Build import android.os.Bundle import android.util.Log import android.view.KeyEvent import android.view.View import android.view.WindowInsets -import android.view.WindowManager import com.expensify.chat.bootsplash.BootSplash -import com.expensify.chat.bootsplash.BootSplashDialog +import com.expensify.chat.bootsplash.PrivacyCover import com.expensify.chat.intenthandler.IntentHandlerFactory import com.expensify.reactnativekeycommand.KeyCommandModule import com.facebook.react.ReactActivity @@ -25,19 +21,6 @@ import expo.modules.ReactActivityDelegateWrapper class MainActivity : ReactActivity() { companion object { private const val APP_START_TIME_PREFERENCES = "AppStartTime" - - // Recents shows this card instead of a snapshot, since snapshots are disabled for privacy. - fun applyTaskDescription(activity: Activity) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - return - } - activity.setTaskDescription( - ActivityManager.TaskDescription.Builder() - .setBackgroundColor(activity.getColor(R.color.bootsplash_background)) - .setStatusBarColor(activity.getColor(R.color.bootsplash_background)) - .build() - ) - } } /** @@ -141,12 +124,29 @@ class MainActivity : ReactActivity() { super.onStart() } - // It lets us tell "leaving the app" from "internal window" + // 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 + + // Overridden, not called: intercepts every activity start + @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() @@ -157,6 +157,7 @@ class MainActivity : ReactActivity() { 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. @@ -166,7 +167,7 @@ class MainActivity : ReactActivity() { override fun onApplyThemeResource(theme: Resources.Theme, resid: Int, first: Boolean) { super.onApplyThemeResource(theme, resid, first) - applyTaskDescription(this) + PrivacyCover.applyTaskDescription(this) } override fun onDestroy() { @@ -174,26 +175,14 @@ class MainActivity : ReactActivity() { super.onDestroy() } - private var privacyDialog: BootSplashDialog? = null - private fun showPrivacyDialog() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || privacyDialog != null || isFinishing || isDestroyed) { + if (startedActivityOurselves) { return } - val dialog = BootSplashDialog(this, R.style.BootTheme) - // When the user comes back the activity underneath regains focus - dialog.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 - dialog.window?.setWindowAnimations(0) - dialog.show() - privacyDialog = dialog + PrivacyCover.show(this) } private fun hidePrivacyDialog() { - privacyDialog?.dismiss() - privacyDialog = null + PrivacyCover.hide(this) } } 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 520613d012e3..305f2fd1593e 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -13,6 +13,7 @@ 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 @@ -58,7 +59,8 @@ class MainApplication : MultiDexApplication(), ReactApplication { activity.setRecentsScreenshotEnabled(false) // Third-party activities (e.g. Plaid) don't set our TaskDescription, so their recents fallback card would be black. if (!activity.javaClass.name.startsWith("com.expensify.")) { - MainActivity.applyTaskDescription(activity) + PrivacyCover.applyTaskDescription(activity) + PrivacyCover.observeWindowFocus(activity) } } if (activity.javaClass.name == "com.plaid.internal.link.LinkActivity") { @@ -70,7 +72,9 @@ class MainApplication : MultiDexApplication(), ReactApplication { override fun onActivityPaused(activity: Activity) {} override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) {} + override fun onActivityDestroyed(activity: Activity) { + PrivacyCover.hide(activity) + } }) ReactFontManager.getInstance().addCustomFont(this, "Custom Emoji Font", R.font.custom_emoji_font) 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..3f7aa418a509 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java @@ -0,0 +1,78 @@ +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; +import java.util.HashMap; +import java.util.Map; + +// Covers the UI with the boot splash while the app is leaving the foreground +public final class PrivacyCover { + + private static final Map covers = new HashMap<>(); + + 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() + || covers.get(activity) != 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(); + covers.put(activity, dialog); + } + + public static void hide(Activity activity) { + BootSplashDialog dialog = covers.remove(activity); + if (dialog != null) { + dialog.dismiss(); + } + } + + // Activities we don't own (e.g. Plaid) can't override the focus callbacks, so we observe the window instead + public static void observeWindowFocus(Activity activity) { + if (!isSupported()) { + return; + } + activity.getWindow().getDecorView().getViewTreeObserver() + .addOnWindowFocusChangeListener(hasFocus -> { + if (hasFocus) { + hide(activity); + } else { + show(activity); + } + }); + } + + // 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() + .setLabel(activity.getString(R.string.app_name)) + .setIcon(R.mipmap.ic_launcher) + .setPrimaryColor(activity.getColor(R.color.brand_dark_green)) + .setBackgroundColor(activity.getColor(R.color.bootsplash_background)) + .setStatusBarColor(activity.getColor(R.color.bootsplash_background)) + .build()); + } +} diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 94065c5b9d19..1900a40fbe68 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -7,4 +7,5 @@ #7D8B8F #07271F #1A3D32 + #FF002E22 From b22a57af642520b961755652465a5b6e90a1b1bb Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 15 Oct 2026 22:17:41 +0200 Subject: [PATCH 4/6] Drop splash cover in Plaid on Android bcs of unfixable bug with popups and screens --- .../java/com/expensify/chat/MainApplication.kt | 2 +- .../expensify/chat/bootsplash/PrivacyCover.java | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) 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 305f2fd1593e..495992484da2 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -58,9 +58,9 @@ class MainApplication : MultiDexApplication(), ReactApplication { 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) - PrivacyCover.observeWindowFocus(activity) } } if (activity.javaClass.name == "com.plaid.internal.link.LinkActivity") { 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 index 3f7aa418a509..7040358dc7ec 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java @@ -47,21 +47,6 @@ public static void hide(Activity activity) { } } - // Activities we don't own (e.g. Plaid) can't override the focus callbacks, so we observe the window instead - public static void observeWindowFocus(Activity activity) { - if (!isSupported()) { - return; - } - activity.getWindow().getDecorView().getViewTreeObserver() - .addOnWindowFocusChangeListener(hasFocus -> { - if (hasFocus) { - hide(activity); - } else { - show(activity); - } - }); - } - // Recents shows this card instead of a snapshot, since snapshots are disabled for privacy. public static void applyTaskDescription(Activity activity) { if (!isSupported()) { From e9e4cf477756567f2801d45e2eeddec9ab649ced Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 15 Oct 2026 22:47:41 +0200 Subject: [PATCH 5/6] Trim privacy cover TaskDescription and skip cover in multi-window --- .../app/src/main/java/com/expensify/chat/MainApplication.kt | 4 +--- .../java/com/expensify/chat/bootsplash/PrivacyCover.java | 5 +---- android/app/src/main/res/values/colors.xml | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) 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 495992484da2..033008cf8a02 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -72,9 +72,7 @@ class MainApplication : MultiDexApplication(), ReactApplication { override fun onActivityPaused(activity: Activity) {} override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) { - PrivacyCover.hide(activity) - } + override fun onActivityDestroyed(activity: Activity) {} }) ReactFontManager.getInstance().addCustomFont(this, "Custom Emoji Font", R.font.custom_emoji_font) 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 index 7040358dc7ec..e2e948510085 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java @@ -23,7 +23,7 @@ public static boolean isSupported() { public static void show(Activity activity) { if (!isSupported() || activity.isFinishing() || activity.isDestroyed() - || covers.get(activity) != null) { + || activity.isInMultiWindowMode() || covers.get(activity) != null) { return; } BootSplashDialog dialog = new BootSplashDialog(activity, R.style.BootTheme); @@ -53,9 +53,6 @@ public static void applyTaskDescription(Activity activity) { return; } activity.setTaskDescription(new ActivityManager.TaskDescription.Builder() - .setLabel(activity.getString(R.string.app_name)) - .setIcon(R.mipmap.ic_launcher) - .setPrimaryColor(activity.getColor(R.color.brand_dark_green)) .setBackgroundColor(activity.getColor(R.color.bootsplash_background)) .setStatusBarColor(activity.getColor(R.color.bootsplash_background)) .build()); diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 1900a40fbe68..94065c5b9d19 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -7,5 +7,4 @@ #7D8B8F #07271F #1A3D32 - #FF002E22 From bdb8770f4cde7cbed701aa17484da53ccd803719 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 22 Oct 2026 19:07:49 +0200 Subject: [PATCH 6/6] Replace privacy cover map with a single field and clarify override comment --- .../java/com/expensify/chat/MainActivity.kt | 4 ++-- .../expensify/chat/bootsplash/PrivacyCover.java | 17 ++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) 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 520f048652af..91ce8a396922 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.kt +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.kt @@ -131,7 +131,7 @@ class MainActivity : ReactActivity() { // It lets us tell "leaving the app" from "another activity we opened ourselves" (e.g. Plaid) private var startedActivityOurselves = false - // Overridden, not called: intercepts every activity start + // 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 @@ -183,6 +183,6 @@ class MainActivity : ReactActivity() { } private fun hidePrivacyDialog() { - PrivacyCover.hide(this) + PrivacyCover.hide() } } 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 index e2e948510085..b00190b4ab14 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/PrivacyCover.java @@ -6,13 +6,12 @@ import android.view.Window; import android.view.WindowManager; import com.expensify.chat.R; -import java.util.HashMap; -import java.util.Map; // Covers the UI with the boot splash while the app is leaving the foreground public final class PrivacyCover { - private static final Map covers = new HashMap<>(); + // Only MainActivity shows the cover + private static BootSplashDialog cover; private PrivacyCover() {} @@ -23,7 +22,7 @@ public static boolean isSupported() { public static void show(Activity activity) { if (!isSupported() || activity.isFinishing() || activity.isDestroyed() - || activity.isInMultiWindowMode() || covers.get(activity) != null) { + || activity.isInMultiWindowMode() || cover != null) { return; } BootSplashDialog dialog = new BootSplashDialog(activity, R.style.BootTheme); @@ -37,13 +36,13 @@ public static void show(Activity activity) { window.setWindowAnimations(0); } dialog.show(); - covers.put(activity, dialog); + cover = dialog; } - public static void hide(Activity activity) { - BootSplashDialog dialog = covers.remove(activity); - if (dialog != null) { - dialog.dismiss(); + public static void hide() { + if (cover != null) { + cover.dismiss(); + cover = null; } }