Skip to content
Draft
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
30 changes: 30 additions & 0 deletions android/app/src/main/java/io/metamask/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,33 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
import io.branch.rnbranch.RNBranchModule
import io.metamask.nativeModules.NotificationModule
import io.metamask.nativeModules.PrivacyCover
import com.braze.reactbridge.BrazeReactUtils

class MainActivity : ReactActivity() {
override fun onPause() {
PrivacyCover.markAuthenticationUnavailable()
PrivacyCover.show(this)
super.onPause()
}

override fun onPostResume() {
super.onPostResume()
PrivacyCover.markAuthenticationReady()
}

/**
* Predictive back calls this from React Native's dispatcher callback.
* Swallow it while the cover is up so it cannot pop the screen underneath.
*/
@Suppress("OVERRIDE_DEPRECATION")
override fun onBackPressed() {
if (PrivacyCover.isShown()) {
return
}
super.onBackPressed()
}

override fun onCreate(savedInstanceState: Bundle?) {
// Capture Notification Intent
NotificationModule.saveNotificationIntent(intent)
Expand All @@ -24,6 +48,12 @@ class MainActivity : ReactActivity() {
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme)
super.onCreate(null)
// Android 13+ can suppress the Recents thumbnail without FLAG_SECURE, so
// screenshots keep working while the app is open. Older versions hold
// FLAG_SECURE for the wallet session instead (see AppLockService).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setRecentsScreenshotEnabled(false)
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/io/metamask/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import io.metamask.nativeModules.RCTMinimizerPackage
import io.metamask.nativeModules.RNTar.RNTarPackage
import io.metamask.nativeModules.BrazePushPackage
import io.metamask.nativeModules.NotificationPackage
import io.metamask.nativeModules.PrivacyCoverPackage
import io.metamask.nativeModules.HermesProfiler.HermesProfilerPackage
import com.braze.BrazeActivityLifecycleCallbackListener
import com.margelo.nitro.nitrofetch.AutoPrefetcher
Expand All @@ -46,6 +47,7 @@ class MainApplication : Application(), ShareApplication, ReactApplication {
add(RCTMinimizerPackage())
add(RNTarPackage())
add(NotificationPackage())
add(PrivacyCoverPackage())
add(BrazePushPackage())
if (BuildConfig.IS_PERFORMANCE_TEST) {
add(HermesProfilerPackage())
Expand Down
129 changes: 129 additions & 0 deletions android/app/src/main/java/io/metamask/nativeModules/PrivacyCover.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package io.metamask.nativeModules

import android.app.Activity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import com.facebook.react.bridge.Promise
import io.metamask.R

/**
* The Android privacy cover. [io.metamask.MainActivity] shows it from `onPause`
* and [PrivacyCoverModule] hides it once resume routing resolves.
*
* Theme fill plus the centered splash fox, matching the iOS cover. Recents stays
* a blank card: Android 13+ uses `setRecentsScreenshotEnabled`, older versions
* hold FLAG_SECURE from login until logout. The snapshot is taken before
* `onPause`, so this view is what the user sees on the way back in.
*/
internal object PrivacyCover {
private var overlay: View? = null
/** Consumes system back while the cover is visible so the screen underneath stays put. */
private var backCallback: OnBackPressedCallback? = null
private var backCallbackActivity: Activity? = null
/** Bumped on every pause so a stale resume cannot start authentication. */
private var authenticationEpoch = 0
/** Set to [authenticationEpoch] only while `onPostResume` is the latest lifecycle event. */
private var resumedEpoch: Int? = null
private val authenticationWaiters = mutableListOf<Pair<Int, Promise>>()

fun show(activity: Activity) {
val decor = activity.window.decorView as? ViewGroup ?: return
val existing = overlay
val cover = if (existing != null && existing.context === activity) {
existing
} else {
View(activity).apply {
setBackgroundResource(R.drawable.app_background)
isClickable = true
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
}.also { overlay = it }
}

if (cover.parent !== decor) {
(cover.parent as? ViewGroup)?.removeView(cover)
decor.addView(
cover,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
}
cover.bringToFront()
cover.visibility = View.VISIBLE
blockBack(activity)
}

fun isShown(): Boolean = overlay?.visibility == View.VISIBLE

fun hide() {
// A dismiss queued while the activity is pausing belongs to a resume
// that is no longer current. Dropping it here keeps the cover up.
if (resumedEpoch == null) {
return
}
overlay?.visibility = View.GONE
backCallback?.isEnabled = false
}

/**
* Registered after React Native's own back callback, so while it is enabled
* the dispatcher delivers back here first and the screen underneath does not move.
*/
private fun blockBack(activity: Activity) {
val componentActivity = activity as? ComponentActivity ?: return
if (backCallback == null || backCallbackActivity !== componentActivity) {
backCallback?.remove()
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// The privacy cover is up. Drop the press.
}
}
componentActivity.onBackPressedDispatcher.addCallback(componentActivity, callback)
backCallback = callback
backCallbackActivity = componentActivity
} else {
backCallback?.isEnabled = true
}
}

/**
* Authentication may start. Resolves waiters for this resume only.
* Call from [android.app.Activity.onPostResume].
*/
fun markAuthenticationReady() {
resumedEpoch = authenticationEpoch
val ready = authenticationWaiters.filter { it.first == authenticationEpoch }
authenticationWaiters.removeAll { it.first == authenticationEpoch }
ready.forEach { it.second.resolve(null) }
}

/**
* Invalidates the current resume. Pending authentication waits fail and
* must be requested again after the next [markAuthenticationReady].
* Call from [android.app.Activity.onPause] before `super.onPause()`.
*/
fun markAuthenticationUnavailable() {
resumedEpoch = null
authenticationEpoch += 1
val waiting = authenticationWaiters.toList()
authenticationWaiters.clear()
waiting.forEach { (_, promise) ->
promise.reject(
"ACTIVITY_PAUSED",
"The activity paused before authentication could start",
)
}
}

fun waitUntilAuthenticationReady(promise: Promise) {
if (resumedEpoch == authenticationEpoch) {
promise.resolve(null)
return
}
authenticationWaiters.add(authenticationEpoch to promise)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.metamask.nativeModules

import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.UiThreadUtil

/**
* Native bridge for [PrivacyCover]. `hide` dismisses the cover once JavaScript
* resume routing has resolved. `waitUntilAuthenticationReady` resolves after
* `onPostResume`, which is when Android will accept a biometric prompt.
* Showing the cover stays on the activity lifecycle.
*/
class PrivacyCoverModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {

override fun getName(): String = "PrivacyCoverModule"

@ReactMethod
fun hide() {
UiThreadUtil.runOnUiThread { PrivacyCover.hide() }
}

/**
* Resolves once [android.app.Activity.onPostResume] has run for the
* current resume. Rejects if [android.app.Activity.onPause] invalidates
* that resume first.
*/
@ReactMethod
fun waitUntilAuthenticationReady(promise: Promise) {
UiThreadUtil.runOnUiThread { PrivacyCover.waitUntilAuthenticationReady(promise) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.metamask.nativeModules

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class PrivacyCoverPackage : ReactPackage {

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()

@Suppress("OVERRIDE_DEPRECATION")
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(PrivacyCoverModule(reactContext))
}
33 changes: 0 additions & 33 deletions app/components/Nav/App/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,6 @@ jest.mock('../../Views/ProHub/screens/Earned', () => () => (
jest.mock('../../Views/ProHub/screens/CancelMembership', () => () => (
<MockView testID="mock-pro-hub-cancel-membership" />
));
jest.mock('../../Views/LockScreen', () => () => (
<MockView testID="mock-lock-screen" />
));
jest.mock('../../Views/MultichainAccounts/AddressList', () => ({
AddressList: () => <MockView testID="mock-address-list" />,
}));
Expand Down Expand Up @@ -710,10 +707,6 @@ describe('App', () => {
expect(Routes.EDIT_NETWORK).toBeDefined();
});

it('has lock screen route defined', () => {
expect(Routes.LOCK_SCREEN).toBeDefined();
});

it('has confirmation routes defined', () => {
expect(Routes.CONFIRMATION_REQUEST_MODAL).toBeDefined();
expect(Routes.CONFIRMATION_SWITCH_ACCOUNT_TYPE).toBeDefined();
Expand Down Expand Up @@ -857,19 +850,6 @@ describe('App', () => {
expect(getByTestId(MOCK_FOX_LOADER_ID)).toBeTruthy();
});
});

it('renders the lock screen route', async () => {
const routeState = {
index: 0,
routes: [{ name: Routes.LOCK_SCREEN }],
};

const { toJSON } = renderAppWithDefaultState(routeState);

await waitFor(() => {
expect(toJSON()).toBeTruthy();
});
});
});

describe('Onboarding navigation', () => {
Expand Down Expand Up @@ -2237,19 +2217,6 @@ describe('App', () => {
expect(getByTestId('mock-pk-list')).toBeOnTheScreen();
});
});

it('renders the LockScreen route', async () => {
const routeState = {
index: 0,
routes: [{ name: Routes.LOCK_SCREEN }],
};

const { getByTestId } = renderAppAtRoute(routeState);

await waitFor(() => {
expect(getByTestId('mock-lock-screen')).toBeTruthy();
});
});
});

describe('isNetworkUiRedesignEnabled conditional rendering', () => {
Expand Down
6 changes: 0 additions & 6 deletions app/components/Nav/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ import ReturnToAppNotification from '../../Views/ReturnToAppNotification';
import EditAccountName from '../../Views/EditAccountName/EditAccountName';
import LegacyEditMultichainAccountName from '../../Views/MultichainAccounts/sheets/EditAccountName';
import { EditMultichainAccountName } from '../../Views/MultichainAccounts/sheets/EditMultichainAccountName';
import LockScreen from '../../Views/LockScreen';
import StorageWrapper from '../../../store/storage-wrapper';
import ShowIpfsGatewaySheet from '../../Views/ShowIpfsGatewaySheet/ShowIpfsGatewaySheet';
import ShowDisplayNftMediaSheet from '../../Views/ShowDisplayMediaNFTSheet/ShowDisplayNFTMediaSheet';
Expand Down Expand Up @@ -1353,11 +1352,6 @@ const AppFlow = () => {
/>
) : null}
</NativeStack.Group>
<NativeStack.Screen
name={Routes.LOCK_SCREEN}
component={LockScreen}
options={{ gestureEnabled: false }}
/>
<NativeStack.Screen
name={Routes.CONFIRMATION_REQUEST_MODAL}
options={{
Expand Down
6 changes: 3 additions & 3 deletions app/components/UI/Card/routes/OnboardingNavigator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ jest.mock('@react-navigation/native-stack', () => {
};
});

jest.mock('../../../../core/LockManagerService', () => ({
jest.mock('../../../../core/AppLock/AppLockService', () => ({
__esModule: true,
default: {
stopListening: jest.fn(),
startListening: jest.fn(),
dangerousPauseAutoLock: jest.fn(),
dangerousResumeAutoLock: jest.fn(),
},
}));

Expand Down
Loading
Loading