Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions android/app/src/main/java/com/expensify/chat/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?) {
Comment on lines +135 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it safe to use the deprecated method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is. The method lives in two places: the platform Activity, where it's not deprecated and the androidx ComponentActivity we extend, which overrides it and marks it deprecated. The deprecation just tells callers to launch activities with registerForActivityResult instead, the method itself is not going away. We don't call it, we override it to catch activity starts: startActivity() and registerForActivityResult both end up going through it.

I edited the comment to make it clearer.

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()
}
}
10 changes: 10 additions & 0 deletions android/app/src/main/java/com/expensify/chat/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"AUTOREIMBURSED",
"AUTOREPORTING",
"AVURL",
"Backgrounding",
"Accelo",
"Addendums",
"Aeroplan",
Expand Down Expand Up @@ -409,6 +410,7 @@
"Subprocessors",
"Subviews",
"Supercenters",
"Superview",
"Svenska",
"Svmy",
"Swedbank",
Expand Down Expand Up @@ -976,6 +978,7 @@
"removeHiddenElems",
"requestee",
"reserialization",
"resid",
"resizeable",
"resultsbox",
"retryable",
Expand Down
67 changes: 67 additions & 0 deletions ios/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
Expand Down Expand Up @@ -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, *) {
Expand Down
13 changes: 10 additions & 3 deletions ios/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading