diff --git a/apps/mobile/knip.json b/apps/mobile/knip.json
index f68e585572..06f9610c56 100644
--- a/apps/mobile/knip.json
+++ b/apps/mobile/knip.json
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
- "entry": ["src/app/**/*.{ts,tsx}"],
+ "entry": ["src/app/**/*.{ts,tsx}", "src/lib/local-access-privacy.ts"],
"project": ["src/**/*.{ts,tsx}"],
"ignoreDependencies": [
"expo-updates",
diff --git a/apps/mobile/modules/local-access-privacy/android/build.gradle b/apps/mobile/modules/local-access-privacy/android/build.gradle
new file mode 100644
index 0000000000..b6dbdf2799
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/build.gradle
@@ -0,0 +1,19 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.localaccessprivacy'
+version = '1.0.0'
+
+android {
+ namespace 'expo.modules.localaccessprivacy'
+ defaultConfig {
+ versionCode 1
+ versionName '1.0.0'
+ }
+}
+
+dependencies {
+ implementation 'androidx.fragment:fragment-ktx:1.8.9'
+}
diff --git a/apps/mobile/modules/local-access-privacy/android/src/main/AndroidManifest.xml b/apps/mobile/modules/local-access-privacy/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/ApplicationWindowCover.kt b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/ApplicationWindowCover.kt
new file mode 100644
index 0000000000..ab8e2252f6
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/ApplicationWindowCover.kt
@@ -0,0 +1,243 @@
+package expo.modules.localaccessprivacy
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Color
+import android.os.Build
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.view.Window
+import android.view.WindowManager
+import android.view.accessibility.AccessibilityEvent
+import android.view.inputmethod.InputMethodManager
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import java.lang.ref.WeakReference
+
+internal class ApplicationWindowCover(
+ val window: Window,
+ val activity: Activity?,
+ private val activityWindow: Boolean,
+ private val onChange: () -> Unit,
+ private val onDetach: () -> Unit
+) {
+ private val decor = window.decorView as ViewGroup
+ private val opacity = FrameLayout(decor.context).apply {
+ setBackgroundColor(Color.BLACK)
+ isClickable = true
+ isFocusableInTouchMode = true
+ }
+ private val previousAccessibility = mutableMapOf()
+ private val wasSecure = window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0
+ private val previousCallback = requireNotNull(window.callback)
+ private var covered = false
+ private var savedFocus: WeakReference? = null
+ private var savedTitle: CharSequence? = null
+ private var savedAccessibilityTitle: CharSequence? = null
+ private var renderedGeneration = -1L
+ private var renderedGate: PrivacyGate? = null
+
+ private val focusListener = ViewTreeObserver.OnWindowFocusChangeListener {
+ onChange()
+ }
+ private val preDraw = ViewTreeObserver.OnPreDrawListener {
+ // Modal recreation can replace a decor between JS events. Never traverse uncovered content.
+ onChange()
+ if (covered) {
+ suppressContent()
+ if (opacity.parent !== decor) return@OnPreDrawListener false
+ opacity.measure(
+ View.MeasureSpec.makeMeasureSpec(decor.width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(decor.height, View.MeasureSpec.EXACTLY))
+ opacity.layout(0, 0, decor.width, decor.height)
+ }
+ true
+ }
+ private val attachment = object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(view: View) { onChange() }
+ override fun onViewDetachedFromWindow(view: View) { onDetach() }
+ }
+ private val guardedCallback = object : Window.Callback by previousCallback {
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (!covered) return previousCallback.dispatchKeyEvent(event)
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ val direction = when (event.keyCode) {
+ KeyEvent.KEYCODE_TAB -> if (event.isShiftPressed) View.FOCUS_BACKWARD else View.FOCUS_FORWARD
+ KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN
+ KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP
+ KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT
+ KeyEvent.KEYCODE_DPAD_RIGHT -> View.FOCUS_RIGHT
+ else -> null
+ }
+ if (direction != null) {
+ val finder = android.view.FocusFinder.getInstance()
+ val next = finder.findNextFocus(opacity, opacity.findFocus(), direction)
+ ?: finder.findNextFocus(opacity, null, direction)
+ next?.requestFocus()
+ return true
+ }
+ }
+ opacity.dispatchKeyEvent(event)
+ return true
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean =
+ if (covered) opacity.dispatchGenericMotionEvent(event) else previousCallback.dispatchGenericMotionEvent(event)
+
+ override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean {
+ if (!covered) return previousCallback.dispatchPopulateAccessibilityEvent(event)
+ event.text.clear()
+ event.contentDescription = null
+ return true
+ }
+ }
+
+ init {
+ decor.viewTreeObserver.addOnWindowFocusChangeListener(focusListener)
+ decor.viewTreeObserver.addOnPreDrawListener(preDraw)
+ decor.addOnAttachStateChangeListener(attachment)
+ window.callback = guardedCallback
+ }
+
+ fun hasFocus(): Boolean = decor.hasWindowFocus() && decor.isAttachedToWindow
+
+ fun resetGate() { renderedGeneration = -1 }
+
+ fun apply(armed: Boolean, hide: Boolean, gate: PrivacyGate?, generation: Long, onAction: (Long, String) -> Unit) {
+ val secure = window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0
+ if (armed && !secure) {
+ // Expo supplies the initial Activity flag. Retain it on recreated Activities and all dialogs.
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ } else if (!armed && !activityWindow && !wasSecure && secure) {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+ if (hide) {
+ if (!covered) {
+ savedFocus = decor.findFocus()?.let { WeakReference(it) }
+ savedTitle = window.attributes.title
+ if (Build.VERSION.SDK_INT >= 26) savedAccessibilityTitle = window.attributes.accessibilityTitle
+ // Hide the IME, not the draft. Restore its application focus only after access publication.
+ val keyboard = decor.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ keyboard.hideSoftInputFromWindow(decor.windowToken, 0)
+ decor.findFocus()?.clearFocus()
+ }
+ covered = true
+ if (window.attributes.title.isNotEmpty()) window.setTitle("")
+ if (Build.VERSION.SDK_INT >= 26 && window.attributes.accessibilityTitle != "") {
+ val attributes = window.attributes
+ attributes.accessibilityTitle = ""
+ window.attributes = attributes
+ }
+ suppressContent()
+ if (opacity.parent == null) decor.addView(opacity, ViewGroup.LayoutParams(-1, -1))
+ opacity.importantForAccessibility = if (gate == null) View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
+ renderGate(gate, generation, onAction)
+ } else if (covered) {
+ covered = false
+ decor.removeView(opacity)
+ restoreAccessibility()
+ savedTitle?.let { window.setTitle(it) }
+ if (Build.VERSION.SDK_INT >= 26) {
+ val attributes = window.attributes
+ attributes.accessibilityTitle = savedAccessibilityTitle
+ window.attributes = attributes
+ }
+ renderedGate = null
+ renderedGeneration = -1
+ }
+ }
+
+ private fun suppressContent() {
+ var elevation = 0f
+ for (index in 0 until decor.childCount) {
+ val child = decor.getChildAt(index)
+ if (child === opacity) continue
+ previousAccessibility.putIfAbsent(child, child.importantForAccessibility)
+ child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ elevation = maxOf(elevation, child.elevation)
+ }
+ opacity.elevation = elevation + 1f
+ if (opacity.parent === decor && decor.getChildAt(decor.childCount - 1) !== opacity) opacity.bringToFront()
+ }
+
+ private fun restoreAccessibility() {
+ previousAccessibility.forEach { (view, previous) -> view.importantForAccessibility = previous }
+ previousAccessibility.clear()
+ }
+
+ private fun renderGate(gate: PrivacyGate?, generation: Long, onAction: (Long, String) -> Unit) {
+ if (renderedGeneration == generation && renderedGate === gate) return
+ opacity.removeAllViews()
+ renderedGeneration = generation
+ renderedGate = gate
+ if (gate == null) return
+ val scroll = ScrollView(decor.context)
+ val stack = LinearLayout(decor.context).apply {
+ orientation = LinearLayout.VERTICAL
+ val padding = (24 * resources.displayMetrics.density).toInt()
+ setPadding(padding, padding, padding, padding)
+ }
+ for (text in listOf(gate.title, gate.message)) {
+ stack.addView(TextView(decor.context).apply {
+ this.text = text
+ setTextColor(Color.WHITE)
+ textSize = 20f
+ })
+ }
+ for (action in gate.actions) {
+ stack.addView(Button(decor.context).apply {
+ text = action.label
+ isEnabled = action.enabled
+ minHeight = (48 * resources.displayMetrics.density).toInt()
+ setOnClickListener { onAction(generation, action.id) }
+ })
+ }
+ scroll.addView(stack)
+ opacity.addView(scroll, FrameLayout.LayoutParams(-1, -1))
+ if (hasFocus()) {
+ stack.getChildAt(0)?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
+ if (stack.getFocusables(View.FOCUS_FORWARD).firstOrNull { it.isEnabled }?.requestFocus() != true) {
+ opacity.requestFocus()
+ }
+ }
+ }
+
+ fun restoreFocus() {
+ if (covered || !hasFocus()) return
+ val target = savedFocus?.get()
+ if (target?.isAttachedToWindow == true && target.isShown) {
+ target.requestFocus()
+ target.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
+ } else {
+ decor.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
+ }
+ savedFocus = null
+ }
+
+ fun dispose() {
+ if (decor.viewTreeObserver.isAlive) {
+ decor.viewTreeObserver.removeOnPreDrawListener(preDraw)
+ decor.viewTreeObserver.removeOnWindowFocusChangeListener(focusListener)
+ }
+ decor.removeOnAttachStateChangeListener(attachment)
+ decor.removeView(opacity)
+ restoreAccessibility()
+ if (covered) {
+ savedTitle?.let { window.setTitle(it) }
+ if (Build.VERSION.SDK_INT >= 26) {
+ val attributes = window.attributes
+ attributes.accessibilityTitle = savedAccessibilityTitle
+ window.attributes = attributes
+ }
+ covered = false
+ }
+ if (window.callback === guardedCallback) window.callback = previousCallback
+ if (!activityWindow && !wasSecure) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacy.kt b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacy.kt
new file mode 100644
index 0000000000..805069ae68
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacy.kt
@@ -0,0 +1,299 @@
+package expo.modules.localaccessprivacy
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import android.view.Window
+import android.view.WindowId
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityManager
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.FragmentManager
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.interfaces.ExtraWindowEventListener
+import com.facebook.react.views.modal.ReactModalHostView
+
+internal object LocalAccessPrivacy : Application.ActivityLifecycleCallbacks, ExtraWindowEventListener {
+ var emit: ((String, Map) -> Unit)? = null
+ private val state = PrivacyVisibilityState()
+ private var installed = false
+ private var reactContext: ReactContext? = null
+ private val activities = mutableSetOf()
+ private val resumed = mutableSetOf()
+ private val windows = linkedMapOf()
+ private val detachingFocus = mutableMapOf()
+ private val focusObserver = object : WindowId.FocusObserver() {
+ override fun onFocusGained(token: WindowId?) { refresh() }
+ override fun onFocusLost(token: WindowId?) { refresh() }
+ }
+ private var gate: PrivacyGate? = null
+ private var gateGeneration = -1L
+ private var refreshing = false
+
+ private val fragments = object : FragmentManager.FragmentLifecycleCallbacks() {
+ // Required public pre-show callback. onFragmentStarted follows Dialog.show(), while
+ // onFragmentViewCreated never runs for viewless AlertFragments. Do not replace this hook.
+ @Suppress("DEPRECATION")
+ override fun onFragmentActivityCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
+ if (state.isArmed) registerDialog(f)
+ }
+
+ override fun onFragmentStarted(fm: FragmentManager, f: Fragment) {
+ // Unarmed focus tracking only. Protected dialogs use the earlier pre-show callback.
+ if (!state.isArmed) registerDialog(f)
+ }
+
+ override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
+ if (f is DialogFragment) f.dialog?.window?.let(::unregister)
+ }
+ }
+
+ fun install(application: Application) {
+ if (installed) return
+ installed = true
+ application.registerActivityLifecycleCallbacks(this)
+ }
+
+ fun attach(context: ReactContext) {
+ if (reactContext !== context) {
+ detach()
+ reactContext = context
+ context.addExtraWindowEventListener(this)
+ }
+ context.currentActivity?.let(::registerActivity)
+ // ReactContext does not replay extra-window events. Seed exposed Modal/fragment windows.
+ activities.toList().forEach { activity ->
+ seedModals(activity.window.decorView, activity)
+ if (activity is FragmentActivity) seedFragments(activity.supportFragmentManager)
+ }
+ refresh()
+ }
+
+ fun detach() {
+ reactContext?.removeExtraWindowEventListener(this)
+ reactContext = null
+ }
+
+ private fun seedModals(view: View, activity: Activity) {
+ if (view is ReactModalHostView) view.dialog?.window?.let { register(it, activity, false) }
+ if (view is ViewGroup) {
+ for (index in 0 until view.childCount) seedModals(view.getChildAt(index), activity)
+ }
+ }
+
+ private fun seedFragments(manager: FragmentManager) {
+ manager.fragments.forEach { fragment ->
+ registerDialog(fragment)
+ seedFragments(fragment.childFragmentManager)
+ }
+ }
+
+ private fun registerDialog(fragment: Fragment) {
+ if (fragment !is DialogFragment || !fragment.showsDialog) return
+ // AndroidX owns the pre-28 fingerprint prompt. It is authentication UI, not application content.
+ if (fragment.javaClass.name.startsWith("androidx.biometric.")) return
+ val dialog = fragment.dialog ?: return
+ dialog.create()
+ dialog.window?.let { register(it, fragment.requireActivity(), false) }
+ }
+
+ private fun registerActivity(activity: Activity) {
+ if (!activities.add(activity)) return
+ register(activity.window, activity, true)
+ if (activity is FragmentActivity) {
+ activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragments, true)
+ }
+ }
+
+ private fun register(window: Window, activity: Activity?, activityWindow: Boolean) {
+ if (!windows.containsKey(window)) {
+ windows[window] = ApplicationWindowCover(window, activity, activityWindow,
+ onChange = { refresh() }, onDetach = { detachWindow(window) })
+ }
+ refresh()
+ }
+
+ private fun detachWindow(window: Window) {
+ val activity = windows[window]?.activity
+ val nativeId = window.decorView.windowId
+ // View detachment precedes WindowManager removal. Retain the live native identity, not a focus bit.
+ // WindowId remains available in this callback, before View clears its AttachInfo.
+ if (activity != null && window !== activity.window && nativeId != null && !detachingFocus.containsKey(nativeId)) {
+ detachingFocus[nativeId] = activity
+ nativeId.registerFocusObserver(focusObserver)
+ }
+ // Removal can discard the input token before WindowId delivers focus loss.
+ // The attached View's UI Handler runs this checkpoint after the synchronous removal stack returns.
+ // This checkpoint grants no grace period: refresh still queries current native focus.
+ val checkpointPosted = window.decorView.post { refresh() }
+ if (!checkpointPosted) state.fail()
+ unregister(window)
+ if (!checkpointPosted) notifyChange()
+ }
+
+ private fun unregister(window: Window) {
+ windows.remove(window)?.dispose()
+ refresh()
+ }
+
+ override fun onExtraWindowCreate(window: Window) {
+ // React Native emits this synchronously after show, before the scheduled traversal.
+ // Prearmed Activity FLAG_SECURE already reaches the Modal before show.
+ register(window, reactContext?.currentActivity, false)
+ }
+
+ override fun onExtraWindowDestroy(window: Window) {
+ // React Native emits this before Dialog.dismiss(), while the Modal can still own native focus.
+ // Keep coverage until decor detaches; its native identity survives until focus transfers.
+ if (!window.decorView.isAttachedToWindow) unregister(window)
+ }
+
+ override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) = registerActivity(activity)
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = registerActivity(activity)
+ override fun onActivityResumed(activity: Activity) {
+ registerActivity(activity)
+ resumed.add(activity)
+ refresh()
+ }
+ override fun onActivityPrePaused(activity: Activity) = pause(activity)
+ override fun onActivityPaused(activity: Activity) = pause(activity)
+ override fun onActivityStopped(activity: Activity) = pause(activity)
+ override fun onActivityStarted(activity: Activity) = Unit
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
+
+ private fun pause(activity: Activity) {
+ resumed.remove(activity)
+ refresh()
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ resumed.remove(activity)
+ activities.remove(activity)
+ if (activity is FragmentActivity) {
+ activity.supportFragmentManager.unregisterFragmentLifecycleCallbacks(fragments)
+ }
+ windows.values.filter { it.activity === activity }.map { it.window }.forEach(::unregister)
+ refresh()
+ }
+
+ private fun refresh() {
+ if (refreshing) return
+ refreshing = true
+ try {
+ val generation = state.generation
+ // Observe removal without relying on a detached View to deliver another focus callback.
+ detachingFocus.toMap().forEach { (nativeId, activity) ->
+ if (!resumed.contains(activity) || !nativeId.isFocused) {
+ detachingFocus.remove(nativeId)
+ nativeId.unregisterFocusObserver(focusObserver)
+ }
+ }
+ // WindowId queries native ownership instead of each View's last delivered focus callback.
+ // An application transfer can deliver the old window's loss before the new dialog's gain.
+ // No focus event is sent to the TypeScript background clock or authentication service.
+ state.setForeground(windows.values.any {
+ val decor = it.window.decorView
+ resumed.contains(it.activity) && decor.isAttachedToWindow && decor.windowId?.isFocused == true
+ } || detachingFocus.isNotEmpty())
+ windows.values.toList().forEach {
+ it.apply(state.isArmed, state.isCovered,
+ if (state.isForeground && gateGeneration == state.generation) gate else null,
+ state.generation, ::gateAction)
+ }
+ if (generation != state.generation) notifyChange()
+ } catch (error: RuntimeException) {
+ state.fail()
+ notifyChange()
+ throw error
+ } finally {
+ refreshing = false
+ }
+ }
+
+ fun arm() {
+ state.arm()
+ check(installed && reactContext != null) { "Native privacy lifecycle is unavailable" }
+ refresh()
+ notifyChange()
+ }
+
+ fun disarm() {
+ state.disarm()
+ gate = null
+ refresh()
+ notifyChange()
+ }
+
+ fun cover() {
+ state.cover()
+ refresh()
+ notifyChange()
+ }
+
+ fun publish(generation: Long): Boolean {
+ refresh()
+ val wasCovered = state.isCovered
+ if (!state.publish(generation)) return false
+ if (wasCovered) {
+ refresh()
+ // Duplicate publication must not move focus or emit another visibility event.
+ windows.values.firstOrNull { it.hasFocus() }?.restoreFocus()
+ notifyChange()
+ }
+ return true
+ }
+
+ fun snapshot(): Map {
+ refresh()
+ return stateMap()
+ }
+
+ fun foregroundAllowed(): Boolean {
+ refresh()
+ return state.admitsForeground()
+ }
+
+ fun setGate(generation: Long, value: PrivacyGate?): Boolean {
+ refresh()
+ if (!state.isArmed || state.generation != generation) return false
+ gate = value
+ gateGeneration = generation
+ windows.values.forEach { it.resetGate() }
+ refresh()
+ return true
+ }
+
+ private fun gateAction(generation: Long, id: String) {
+ refresh()
+ if (state.isCovered && state.isForeground && state.generation == generation &&
+ gate?.actions?.any { it.id == id && it.enabled } == true) {
+ emit?.invoke("onGateAction", mapOf("generation" to generation, "id" to id))
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ fun announce(message: String, generation: Long, gate: Boolean): Boolean {
+ refresh()
+ if (!state.admitsAnnouncement(generation, gate)) return false
+ val context = reactContext ?: return false
+ val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ if (!manager.isEnabled) return false
+ val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_ANNOUNCEMENT)
+ event.packageName = context.packageName
+ event.className = LocalAccessPrivacyModule::class.java.name
+ event.text.add(message)
+ manager.sendAccessibilityEvent(event)
+ return true
+ }
+
+ private fun stateMap(): Map = mapOf(
+ "generation" to state.generation, "armed" to state.isArmed, "foreground" to state.isForeground,
+ "covered" to state.isCovered, "failed" to state.isFailed)
+
+ private fun notifyChange() { emit?.invoke("onVisibilityChange", stateMap()) }
+}
diff --git a/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacyModule.kt b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacyModule.kt
new file mode 100644
index 0000000000..b7bc2f3d1f
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacyModule.kt
@@ -0,0 +1,67 @@
+package expo.modules.localaccessprivacy
+
+import android.os.Looper
+import com.facebook.react.bridge.UiThreadUtil
+import expo.modules.kotlin.functions.Queues
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.records.Field
+import expo.modules.kotlin.records.Record
+import java.util.concurrent.FutureTask
+
+class PrivacyGateAction : Record {
+ @Field var id: String = ""
+ @Field var label: String = ""
+ @Field var enabled: Boolean = false
+}
+
+class PrivacyGate : Record {
+ @Field var title: String = ""
+ @Field var message: String = ""
+ @Field var actions: List = emptyList()
+}
+
+internal fun onPrivacyMain(body: () -> T): T {
+ if (Looper.myLooper() == Looper.getMainLooper()) return body()
+ val task = FutureTask(body)
+ UiThreadUtil.runOnUiThread(task)
+ return task.get()
+}
+
+class LocalAccessPrivacyModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("LocalAccessPrivacy")
+ Events("onVisibilityChange", "onGateAction")
+
+ OnCreate {
+ onPrivacyMain {
+ // This attachment precedes the shell's successful arm, and thus authenticated Modal creation.
+ LocalAccessPrivacy.attach(requireNotNull(appContext.reactContext))
+ LocalAccessPrivacy.emit = { name, payload -> sendEvent(name, payload) }
+ }
+ }
+ OnDestroy {
+ onPrivacyMain {
+ // Reloading JavaScript does not establish that authenticated windows have unmounted.
+ LocalAccessPrivacy.cover()
+ LocalAccessPrivacy.detach()
+ LocalAccessPrivacy.emit = null
+ }
+ }
+ Function("arm") { onPrivacyMain { LocalAccessPrivacy.arm() } }
+ Function("disarm") { onPrivacyMain { LocalAccessPrivacy.disarm() } }
+ Function("cover") { onPrivacyMain { LocalAccessPrivacy.cover() } }
+ Function("getSnapshot") { onPrivacyMain { LocalAccessPrivacy.snapshot() } }
+ Function("publishVisibility") { generation: Long ->
+ onPrivacyMain { LocalAccessPrivacy.publish(generation) }
+ }
+ Function("isForegroundAllowed") { onPrivacyMain { LocalAccessPrivacy.foregroundAllowed() } }
+ Function("setGate") { generation: Long, gate: PrivacyGate? ->
+ onPrivacyMain { LocalAccessPrivacy.setGate(generation, gate) }
+ }
+ AsyncFunction("announce") { message: String, generation: Long, gate: Boolean ->
+ // The UI-thread boundary rechecks after the native queue wait, not only at JS invocation.
+ LocalAccessPrivacy.announce(message, generation, gate)
+ }.runOnQueue(Queues.MAIN)
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacyPackage.kt b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacyPackage.kt
new file mode 100644
index 0000000000..c68c8a7b05
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacyPackage.kt
@@ -0,0 +1,16 @@
+package expo.modules.localaccessprivacy
+
+import android.app.Application
+import android.content.Context
+import expo.modules.core.interfaces.ApplicationLifecycleListener
+import expo.modules.core.interfaces.Package
+
+class LocalAccessPrivacyPackage : Package {
+ override fun createApplicationLifecycleListeners(context: Context?): List =
+ listOf(object : ApplicationLifecycleListener {
+ override fun onCreate(application: Application) {
+ // Application callbacks do not wait for Expo's loadAppReady lifecycle forwarding.
+ LocalAccessPrivacy.install(application)
+ }
+ })
+}
diff --git a/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/PrivacyVisibilityState.java b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/PrivacyVisibilityState.java
new file mode 100644
index 0000000000..c66afa720b
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/android/src/main/java/expo/modules/localaccessprivacy/PrivacyVisibilityState.java
@@ -0,0 +1,60 @@
+package expo.modules.localaccessprivacy;
+
+// Java keeps this UI-independent state executable with the JDK, without an Android runtime.
+// Authentication and background timing stay in the shared TypeScript service.
+final class PrivacyVisibilityState {
+ private boolean armed;
+ private boolean foreground;
+ private boolean visible;
+ private boolean failed;
+ private long generation;
+
+ boolean isArmed() { return armed; }
+ boolean isForeground() { return foreground; }
+ boolean isFailed() { return failed; }
+ long getGeneration() { return generation; }
+ boolean isCovered() { return armed && (!foreground || !visible || failed); }
+ boolean admitsForeground() { return foreground && (!armed || (!isCovered() && !failed)); }
+
+ void arm() {
+ armed = true;
+ failed = false;
+ cover();
+ }
+
+ void disarm() {
+ armed = false;
+ failed = false;
+ cover();
+ }
+
+ void cover() {
+ visible = false;
+ generation += 1;
+ }
+
+ void setForeground(boolean value) {
+ if (foreground != value) {
+ foreground = value;
+ cover();
+ }
+ }
+
+ void fail() {
+ failed = true;
+ cover();
+ }
+
+ boolean publish(long expectedGeneration) {
+ if (!armed || !foreground || failed || expectedGeneration != generation) {
+ return false;
+ }
+ visible = true;
+ return true;
+ }
+
+ boolean admitsAnnouncement(long expectedGeneration, boolean gate) {
+ return expectedGeneration == generation
+ && (!armed || (foreground && (gate || (!failed && visible))));
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/expo-module.config.json b/apps/mobile/modules/local-access-privacy/expo-module.config.json
new file mode 100644
index 0000000000..48b5134d45
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/expo-module.config.json
@@ -0,0 +1,10 @@
+{
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["LocalAccessPrivacyModule"],
+ "appDelegateSubscribers": ["LocalAccessPrivacyAppDelegateSubscriber"]
+ },
+ "android": {
+ "modules": ["expo.modules.localaccessprivacy.LocalAccessPrivacyModule"]
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacy.podspec b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacy.podspec
new file mode 100644
index 0000000000..efd6ca5a68
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacy.podspec
@@ -0,0 +1,26 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'LocalAccessPrivacy'
+ s.version = package['version']
+ s.summary = package['description']
+ s.description = package['description']
+ s.license = {
+ :type => 'OCVSAL-1.0',
+ :file => File.expand_path('../../../../../LICENSE.md', __dir__)
+ }
+ s.author = 'Kilo'
+ s.homepage = 'https://github.com/Kilo-Org/cloud'
+ s.platforms = { :ios => '16.4' }
+ s.swift_version = '5.9'
+ s.source = { git: 'https://github.com/Kilo-Org/cloud.git' }
+ s.static_framework = true
+ s.dependency 'ExpoModulesCore'
+ s.source_files = '**/*.{h,m,swift}'
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+end
diff --git a/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacy.swift b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacy.swift
new file mode 100644
index 0000000000..3a4b6a4cfb
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacy.swift
@@ -0,0 +1,299 @@
+import UIKit
+
+private final class ApplicationWindowCover {
+ weak var window: UIWindow?
+ weak var responder: UIView?
+ private var previousAccessibility: Bool?
+ private let opacity = UIView()
+
+ init(_ window: UIWindow) {
+ self.window = window
+ opacity.backgroundColor = .systemBackground
+ opacity.isOpaque = true
+ opacity.isAccessibilityElement = false
+ opacity.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ }
+
+ func apply(_ covered: Bool) {
+ guard let window else { return }
+ if covered {
+ if previousAccessibility == nil {
+ previousAccessibility = window.accessibilityElementsHidden
+ responder = firstResponder(window)
+ // Resign the keyboard without changing the uncontrolled composer's text or selection.
+ UIView.performWithoutAnimation { window.endEditing(true) }
+ }
+ window.accessibilityElementsHidden = true
+ opacity.frame = window.bounds
+ if opacity.superview == nil { window.addSubview(opacity) }
+ window.bringSubviewToFront(opacity)
+ } else {
+ opacity.removeFromSuperview()
+ if let previousAccessibility { window.accessibilityElementsHidden = previousAccessibility }
+ previousAccessibility = nil
+ }
+ }
+
+ func restoreFocus() {
+ guard let window, !window.isHidden, !window.accessibilityElementsHidden else { return }
+ if let responder, responder.window === window, !responder.isHidden {
+ responder.becomeFirstResponder()
+ UIAccessibility.post(notification: .screenChanged, argument: responder)
+ } else {
+ var controller = window.rootViewController
+ while let presented = controller?.presentedViewController { controller = presented }
+ UIAccessibility.post(notification: .screenChanged, argument: controller?.view)
+ }
+ responder = nil
+ }
+
+ private func firstResponder(_ view: UIView) -> UIView? {
+ if view.isFirstResponder { return view }
+ for child in view.subviews {
+ if let found = firstResponder(child) { return found }
+ }
+ return nil
+ }
+}
+
+final class LocalAccessPrivacy: NSObject {
+ static let shared = LocalAccessPrivacy()
+ var emit: ((String, [String: Any]) -> Void)?
+ private var state = PrivacyVisibilityState()
+ private var installed = false
+ private var active = false
+ private var inactiveScenes = Set()
+ private var windows: [ObjectIdentifier: ApplicationWindowCover] = [:]
+ private var scenes: [ObjectIdentifier: PrivacySceneWindow] = [:]
+ private var legacyScreens: [ObjectIdentifier: PrivacySceneWindow] = [:]
+ private var covers: [PrivacySceneWindow] { Array(scenes.values) + Array(legacyScreens.values) }
+ private var gate: PrivacyGate?
+ private var gateGeneration = -1
+ private var refreshing = false
+
+ func install() {
+ precondition(Thread.isMainThread)
+ guard !installed else { return }
+ installed = true
+ active = UIApplication.shared.applicationState == .active
+ let center = NotificationCenter.default
+ center.addObserver(self, selector: #selector(sceneInactive), name: UIScene.willDeactivateNotification, object: nil)
+ for name in [UIScene.willConnectNotification, UIScene.didActivateNotification, UIScene.didDisconnectNotification] {
+ center.addObserver(self, selector: #selector(sceneChanged), name: name, object: nil)
+ }
+ for name in [UIWindow.didBecomeVisibleNotification, UIWindow.didBecomeHiddenNotification, UIWindow.didBecomeKeyNotification] {
+ center.addObserver(self, selector: #selector(windowChanged), name: name, object: nil)
+ }
+ refresh()
+ }
+
+ private func isApplicationWindow(_ window: UIWindow) -> Bool {
+ guard !(window is PrivacySceneWindow) else { return false }
+ // Plain UIWindow includes the main window and RCTAlertController's alert+1 window.
+ // UIKit's private keyboard/text-effects/remote prompt windows are not application windows.
+ return type(of: window) == UIWindow.self || Bundle(for: type(of: window)) != Bundle(for: UIWindow.self)
+ }
+
+ private func sceneActive(_ scene: UIWindowScene) -> Bool {
+ active && scene.activationState == .foregroundActive && !inactiveScenes.contains(ObjectIdentifier(scene))
+ }
+
+ func applicationActive(_ value: Bool) {
+ precondition(Thread.isMainThread)
+ let before = state.generation
+ active = value
+ if !value { state.setForeground(false) }
+ refresh(previousGeneration: before)
+ }
+
+ @objc private func sceneInactive(_ notification: Notification) {
+ guard let scene = notification.object as? UIWindowScene else { return }
+ let before = state.generation
+ inactiveScenes.insert(ObjectIdentifier(scene))
+ state.cover()
+ refresh(previousGeneration: before)
+ }
+
+ @objc private func sceneChanged(_ notification: Notification) {
+ guard let scene = notification.object as? UIWindowScene else { return }
+ let before = state.generation
+ let id = ObjectIdentifier(scene)
+ if notification.name == UIScene.didDisconnectNotification {
+ scenes.removeValue(forKey: id)?.isHidden = true
+ inactiveScenes.remove(id)
+ } else if notification.name == UIScene.didActivateNotification {
+ inactiveScenes.remove(id)
+ }
+ state.cover()
+ refresh(additionalScene: notification.name == UIScene.didDisconnectNotification ? nil : scene,
+ previousGeneration: before)
+ }
+
+ @objc private func windowChanged(_ notification: Notification) {
+ guard let window = notification.object as? UIWindow, !(window is PrivacySceneWindow) else { return }
+ if isApplicationWindow(window), !window.isHidden {
+ register(window)
+ }
+ refresh()
+ }
+
+ private func register(_ window: UIWindow) {
+ let id = ObjectIdentifier(window)
+ if windows[id] == nil { windows[id] = ApplicationWindowCover(window) }
+ }
+
+ private func refresh(additionalScene: UIWindowScene? = nil, previousGeneration: Int? = nil) {
+ precondition(Thread.isMainThread)
+ guard !refreshing else { return }
+ refreshing = true
+ defer { refreshing = false }
+ // A lifecycle handler can revoke visibility even when a companion scene keeps foreground true.
+ let before = previousGeneration ?? state.generation
+ var connected = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+ if let additionalScene, !connected.contains(additionalScene) { connected.append(additionalScene) }
+ // Compatibility: RCTAlertController and pre-scene delegates can use UIWindow(frame:).
+ // Keep this public legacy enumeration until both producers require a UIWindowScene.
+ UIApplication.shared.windows.filter { isApplicationWindow($0) && !$0.isHidden }.forEach(register)
+ for scene in connected {
+ let applicationWindows = scene.windows.filter { isApplicationWindow($0) && !$0.isHidden }
+ applicationWindows.forEach(register)
+ let id = ObjectIdentifier(scene)
+ if state.armed, !applicationWindows.isEmpty, scenes[id] == nil {
+ scenes[id] = PrivacySceneWindow(windowScene: scene)
+ }
+ }
+ let legacyWindows = windows.values.compactMap(\.window).filter { $0.windowScene == nil && !$0.isHidden }
+ let legacyFocused = !legacyWindows.isEmpty &&
+ (legacyWindows.contains { $0.isKeyWindow } || legacyScreens.values.contains { $0.isKeyWindow })
+ let sceneFocused = connected.contains { scene in
+ sceneActive(scene) && scene.windows.contains { $0.isKeyWindow && (isApplicationWindow($0) || $0 is PrivacySceneWindow) }
+ }
+ state.setForeground(sceneFocused || (active && legacyFocused))
+ // Save focus and suppress content before the gate can become key or post accessibility focus.
+ for (id, entry) in windows {
+ guard let window = entry.window, !window.isHidden else {
+ entry.apply(false)
+ windows.removeValue(forKey: id)
+ continue
+ }
+ let sceneCovered = window.windowScene.map { !sceneActive($0) } ?? !active
+ entry.apply(state.armed && (state.covered || sceneCovered))
+ }
+ for scene in connected {
+ let applicationWindows = scene.windows.filter { isApplicationWindow($0) && !$0.isHidden }
+ let covered = state.armed && (state.covered || !sceneActive(scene))
+ let topLevel = applicationWindows.map(\.windowLevel.rawValue).max() ?? UIWindow.Level.normal.rawValue
+ scenes[ObjectIdentifier(scene)]?.update(
+ covered: covered,
+ interactive: state.foreground && sceneActive(scene) && gateGeneration == state.generation,
+ level: topLevel + 1,
+ gate: gate,
+ generation: state.generation,
+ onAction: { [weak self] generation, id in self?.gateAction(generation, id: id) }
+ )
+ }
+ updateLegacyWindows(legacyWindows)
+ if before != state.generation { notify() }
+ }
+
+ private func updateLegacyWindows(_ applicationWindows: [UIWindow]) {
+ let groups = Dictionary(grouping: applicationWindows) { ObjectIdentifier($0.screen) }
+ for (id, group) in groups {
+ guard let window = group.first else { continue }
+ if state.armed, legacyScreens[id] == nil {
+ let cover = PrivacySceneWindow(frame: window.screen.bounds)
+ cover.screen = window.screen
+ legacyScreens[id] = cover
+ }
+ let topLevel = group.map(\.windowLevel.rawValue).max() ?? UIWindow.Level.normal.rawValue
+ legacyScreens[id]?.update(
+ covered: state.covered,
+ interactive: state.foreground && gateGeneration == state.generation,
+ level: topLevel + 1,
+ gate: gate,
+ generation: state.generation,
+ keyCandidate: group.first { $0.isKeyWindow },
+ onAction: { [weak self] generation, id in self?.gateAction(generation, id: id) }
+ )
+ }
+ for (id, cover) in legacyScreens where !state.armed || groups[id] == nil {
+ cover.isHidden = true
+ legacyScreens.removeValue(forKey: id)
+ }
+ }
+
+ func arm() {
+ install()
+ state.arm()
+ refresh()
+ notify()
+ }
+
+ func disarm() {
+ state.disarm()
+ gate = nil
+ refresh()
+ covers.forEach { $0.isHidden = true }
+ scenes.removeAll()
+ notify()
+ }
+
+ func cover() {
+ state.cover()
+ refresh()
+ notify()
+ }
+
+ func publish(_ generation: Int) -> Bool {
+ refresh()
+ let wasCovered = state.covered
+ guard state.publish(generation) else { return false }
+ if wasCovered {
+ refresh()
+ // Duplicate publication must not move focus or emit another visibility event.
+ windows.values.first {
+ $0.window?.isKeyWindow == true && $0.window?.accessibilityElementsHidden == false
+ }?.restoreFocus()
+ notify()
+ }
+ return true
+ }
+
+ func snapshot() -> [String: Any] {
+ refresh()
+ return ["generation": state.generation, "armed": state.armed, "foreground": state.foreground,
+ "covered": state.covered, "failed": state.failed]
+ }
+
+ func foregroundAllowed() -> Bool {
+ refresh()
+ return state.admitsForeground
+ }
+
+ func admitsAnnouncement(_ generation: Int, gate: Bool) -> Bool {
+ refresh()
+ return state.admitsAnnouncement(generation, gate: gate)
+ }
+
+ func setGate(_ generation: Int, gate: PrivacyGate?) -> Bool {
+ refresh()
+ guard state.armed, generation == state.generation else { return false }
+ self.gate = gate
+ gateGeneration = generation
+ covers.forEach { $0.resetGate() }
+ refresh()
+ return true
+ }
+
+ private func gateAction(_ generation: Int, id: String) {
+ refresh()
+ guard state.covered, state.foreground, generation == state.generation,
+ gate?.actions.contains(where: { $0.id == id && $0.enabled }) == true else { return }
+ emit?("onGateAction", ["generation": generation, "id": id])
+ }
+
+ private func notify() {
+ emit?("onVisibilityChange", ["generation": state.generation, "armed": state.armed,
+ "foreground": state.foreground, "covered": state.covered, "failed": state.failed])
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacyAppDelegateSubscriber.swift b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacyAppDelegateSubscriber.swift
new file mode 100644
index 0000000000..b0cfe219a9
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacyAppDelegateSubscriber.swift
@@ -0,0 +1,25 @@
+import ExpoModulesCore
+import UIKit
+
+public final class LocalAccessPrivacyAppDelegateSubscriber: ExpoAppDelegateSubscriber {
+ public func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ LocalAccessPrivacy.shared.install()
+ return true
+ }
+
+ public func applicationWillResignActive(_ application: UIApplication) {
+ // Expo forwards this synchronously. Never await JavaScript or an opacity animation.
+ LocalAccessPrivacy.shared.applicationActive(false)
+ }
+
+ public func applicationDidEnterBackground(_ application: UIApplication) {
+ LocalAccessPrivacy.shared.applicationActive(false)
+ }
+
+ public func applicationDidBecomeActive(_ application: UIApplication) {
+ LocalAccessPrivacy.shared.applicationActive(true)
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacyModule.swift b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacyModule.swift
new file mode 100644
index 0000000000..4ad8164151
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/ios/LocalAccessPrivacyModule.swift
@@ -0,0 +1,62 @@
+import ExpoModulesCore
+import UIKit
+
+struct PrivacyGateAction: Record {
+ @Field var id: String = ""
+ @Field var label: String = ""
+ @Field var enabled: Bool = false
+}
+
+struct PrivacyGate: Record {
+ @Field var title: String = ""
+ @Field var message: String = ""
+ @Field var actions: [PrivacyGateAction] = []
+}
+
+// Expo synchronous Functions run on the caller's thread. Visibility changes finish on main before return.
+func onPrivacyMain(_ body: () -> T) -> T {
+ if Thread.isMainThread { return body() }
+ return DispatchQueue.main.sync(execute: body)
+}
+
+public final class LocalAccessPrivacyModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("LocalAccessPrivacy")
+ Events("onVisibilityChange", "onGateAction")
+
+ OnCreate {
+ onPrivacyMain {
+ LocalAccessPrivacy.shared.install()
+ LocalAccessPrivacy.shared.emit = { [weak self] name, payload in
+ self?.sendEvent(name, payload)
+ }
+ }
+ }
+ OnDestroy {
+ onPrivacyMain {
+ // A JavaScript reload is not proof that authenticated windows have unmounted.
+ LocalAccessPrivacy.shared.cover()
+ LocalAccessPrivacy.shared.emit = nil
+ }
+ }
+ Function("arm") { onPrivacyMain { LocalAccessPrivacy.shared.arm() } }
+ Function("disarm") { onPrivacyMain { LocalAccessPrivacy.shared.disarm() } }
+ Function("cover") { onPrivacyMain { LocalAccessPrivacy.shared.cover() } }
+ Function("getSnapshot") { onPrivacyMain { LocalAccessPrivacy.shared.snapshot() } }
+ Function("publishVisibility") { (generation: Int) in
+ onPrivacyMain { LocalAccessPrivacy.shared.publish(generation) }
+ }
+ Function("isForegroundAllowed") {
+ onPrivacyMain { LocalAccessPrivacy.shared.foregroundAllowed() }
+ }
+ Function("setGate") { (generation: Int, gate: PrivacyGate?) in
+ onPrivacyMain { LocalAccessPrivacy.shared.setGate(generation, gate: gate) }
+ }
+ AsyncFunction("announce") { (message: String, generation: Int, gate: Bool) -> Bool in
+ // This is the final native delivery boundary, after the native queue wait.
+ guard LocalAccessPrivacy.shared.admitsAnnouncement(generation, gate: gate) else { return false }
+ UIAccessibility.post(notification: .announcement, argument: message)
+ return true
+ }.runOnQueue(.main)
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/ios/PrivacySceneWindow.swift b/apps/mobile/modules/local-access-privacy/ios/PrivacySceneWindow.swift
new file mode 100644
index 0000000000..78dd309dca
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/ios/PrivacySceneWindow.swift
@@ -0,0 +1,107 @@
+import UIKit
+
+// Scene-owned covers stay above application presentations, not above arbitrary OS windows.
+// The frame initializer preserves RCTAlertController's supported scene-less fallback.
+final class PrivacySceneWindow: UIWindow {
+ private var acceptsKey = false
+ private weak var previousKey: UIWindow?
+ private var renderedGeneration = -1
+ private var gateView: UIView?
+ override var canBecomeKey: Bool { acceptsKey }
+
+ override init(windowScene: UIWindowScene) {
+ super.init(windowScene: windowScene)
+ configure()
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ configure()
+ }
+
+ private func configure() {
+ let controller = UIViewController()
+ controller.view.backgroundColor = .systemBackground
+ controller.view.isOpaque = true
+ rootViewController = controller
+ accessibilityElementsHidden = true
+ }
+
+ required init?(coder: NSCoder) { return nil }
+
+ func resetGate() { renderedGeneration = -1 }
+
+ func update(
+ covered: Bool, interactive: Bool, level: CGFloat, gate: PrivacyGate?, generation: Int,
+ keyCandidate: UIWindow? = nil, onAction: @escaping (Int, String) -> Void
+ ) {
+ if let windowScene { frame = windowScene.coordinateSpace.bounds }
+ windowLevel = UIWindow.Level(rawValue: level)
+ let showGate = covered && interactive && gate != nil
+ acceptsKey = showGate
+ isUserInteractionEnabled = covered && interactive
+ accessibilityElementsHidden = !showGate
+ rootViewController?.view.accessibilityViewIsModal = showGate
+ if !showGate, isKeyWindow {
+ resignKey()
+ if let previousKey, !previousKey.isHidden { previousKey.makeKey() }
+ }
+ isHidden = !covered
+ gateView?.isHidden = !showGate
+ guard showGate, let gate, let root = rootViewController?.view else { return }
+ if renderedGeneration != generation {
+ gateView?.removeFromSuperview()
+ let scroll = UIScrollView()
+ scroll.translatesAutoresizingMaskIntoConstraints = false
+ root.addSubview(scroll)
+ NSLayoutConstraint.activate([
+ scroll.leadingAnchor.constraint(equalTo: root.safeAreaLayoutGuide.leadingAnchor, constant: 24),
+ scroll.trailingAnchor.constraint(equalTo: root.safeAreaLayoutGuide.trailingAnchor, constant: -24),
+ scroll.topAnchor.constraint(equalTo: root.safeAreaLayoutGuide.topAnchor, constant: 24),
+ scroll.bottomAnchor.constraint(equalTo: root.safeAreaLayoutGuide.bottomAnchor, constant: -24)
+ ])
+ let stack = UIStackView()
+ stack.axis = .vertical
+ stack.spacing = 16
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ scroll.addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
+ stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
+ stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
+ stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
+ stack.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor)
+ ])
+ for (text, style) in [(gate.title, UIFont.TextStyle.title1), (gate.message, .body)] {
+ let label = UILabel()
+ label.text = text
+ label.font = .preferredFont(forTextStyle: style)
+ label.adjustsFontForContentSizeCategory = true
+ label.numberOfLines = 0
+ stack.addArrangedSubview(label)
+ }
+ for action in gate.actions {
+ let button = UIButton(type: .system)
+ button.setTitle(action.label, for: .normal)
+ button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
+ button.titleLabel?.adjustsFontForContentSizeCategory = true
+ button.titleLabel?.numberOfLines = 0
+ button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
+ button.isEnabled = action.enabled
+ button.addAction(UIAction { _ in onAction(generation, action.id) }, for: .touchUpInside)
+ stack.addArrangedSubview(button)
+ }
+ gateView = scroll
+ renderedGeneration = generation
+ UIAccessibility.post(notification: .screenChanged, argument: stack.arrangedSubviews.first)
+ }
+ if !isKeyWindow {
+ // Do not replace a system-owned key window, including authentication controls.
+ if let key = windowScene?.windows.first(where: { $0.isKeyWindow }) ?? keyCandidate,
+ type(of: key) == UIWindow.self || Bundle(for: type(of: key)) != Bundle(for: UIWindow.self) {
+ previousKey = key
+ makeKey()
+ }
+ }
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/ios/PrivacyVisibilityState.swift b/apps/mobile/modules/local-access-privacy/ios/PrivacyVisibilityState.swift
new file mode 100644
index 0000000000..e0b33ea05a
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/ios/PrivacyVisibilityState.swift
@@ -0,0 +1,49 @@
+// Native activity and visibility only. Authentication and background timing stay in TypeScript.
+struct PrivacyVisibilityState {
+ private(set) var armed = false
+ private(set) var foreground = false
+ private(set) var visible = false
+ private(set) var failed = false
+ private(set) var generation = 0
+
+ var covered: Bool { armed && (!foreground || !visible || failed) }
+ var admitsForeground: Bool { foreground && (!armed || (!covered && !failed)) }
+
+ mutating func arm() {
+ armed = true
+ failed = false
+ cover()
+ }
+
+ mutating func disarm() {
+ armed = false
+ failed = false
+ cover()
+ }
+
+ mutating func cover() {
+ visible = false
+ generation += 1
+ }
+
+ mutating func setForeground(_ value: Bool) {
+ guard foreground != value else { return }
+ foreground = value
+ cover()
+ }
+
+ mutating func fail() {
+ failed = true
+ cover()
+ }
+
+ mutating func publish(_ expectedGeneration: Int) -> Bool {
+ guard armed, foreground, !failed, expectedGeneration == generation else { return false }
+ visible = true
+ return true
+ }
+
+ func admitsAnnouncement(_ expectedGeneration: Int, gate: Bool) -> Bool {
+ expectedGeneration == generation && (!armed || (foreground && (gate || (!failed && visible))))
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/package.json b/apps/mobile/modules/local-access-privacy/package.json
new file mode 100644
index 0000000000..ea0a7e422b
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "local-access-privacy",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Native application-window visibility boundary",
+ "license": "SEE LICENSE IN ../../../../LICENSE.md"
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/PrivacyVisibilityStateTests.java b/apps/mobile/modules/local-access-privacy/tests/PrivacyVisibilityStateTests.java
new file mode 100644
index 0000000000..f19a0da4e5
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/PrivacyVisibilityStateTests.java
@@ -0,0 +1,53 @@
+package expo.modules.localaccessprivacy;
+
+public final class PrivacyVisibilityStateTests {
+ private static int checks;
+
+ private static void check(boolean value, String message) {
+ if (!value) throw new AssertionError(message);
+ checks += 1;
+ }
+
+ public static void main(String[] args) {
+ PrivacyVisibilityState state = new PrivacyVisibilityState();
+ check(!state.isCovered(), "Disarmed content must retain its old visibility");
+ check(state.admitsAnnouncement(state.getGeneration(), false), "Disarmed announcements must work");
+ check(!state.admitsForeground(), "A missing native foreground must deny effects");
+ state.setForeground(true);
+ state.arm();
+ check(state.isCovered(), "Arming must cover before authenticated content mounts");
+ check(!state.admitsForeground(), "Foreground alone must not authorize armed effects");
+ check(!state.admitsAnnouncement(state.getGeneration(), false), "Protected speech must stay hidden");
+ check(state.admitsAnnouncement(state.getGeneration(), true), "Non-sensitive gate speech must remain usable");
+ long first = state.getGeneration();
+ check(state.publish(first), "Current access publication must reveal content");
+ check(!state.isCovered() && state.admitsForeground(), "Published content must admit effects");
+ check(state.admitsAnnouncement(first, false), "Allowed content must permit speech");
+ state.setForeground(true);
+ check(state.getGeneration() == first && !state.isCovered(), "An app dialog retaining foreground must not revoke visibility");
+ state.setForeground(false);
+ check(state.isCovered() && !state.admitsForeground(), "Inactivity must cover synchronously");
+ check(!state.admitsAnnouncement(first, false), "Queued speech must fail after inactivity");
+ check(!state.admitsAnnouncement(state.getGeneration(), true), "A passive cover must not announce a gate over a prompt");
+ check(!state.publish(state.getGeneration()), "Inactive publication must not expose content");
+ state.setForeground(true);
+ check(state.isCovered(), "Native foreground must never uncover on its own");
+ check(!state.publish(first), "A stale visibility generation must never uncover");
+ check(state.publish(state.getGeneration()), "A fresh readiness handshake must restore visibility");
+ check(!state.admitsAnnouncement(first, false), "Unlock must never replay stale speech");
+ long beforeOwnerChange = state.getGeneration();
+ state.cover();
+ check(!state.publish(beforeOwnerChange), "Owner revocation must invalidate old visibility");
+ state.fail();
+ check(state.isCovered() && !state.publish(state.getGeneration()), "Native failure must remain protected");
+ check(!state.admitsForeground(), "Native failure must deny effects");
+ check(state.admitsAnnouncement(state.getGeneration(), true), "Recovery speech must remain non-sensitive");
+ long beforeDisarm = state.getGeneration();
+ state.disarm();
+ check(!state.isCovered() && state.admitsForeground(), "Unmounted content must release protection");
+ check(!state.admitsAnnouncement(beforeDisarm, false), "Disarm must not deliver old account speech");
+ check(state.admitsAnnouncement(state.getGeneration(), false), "Fresh disarmed speech must work");
+ check(!state.publish(state.getGeneration()), "Publication cannot arm an empty shell");
+ System.out.println("PrivacyVisibilityState Java: " + checks + " checks passed");
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/PrivacyVisibilityStateTests.swift b/apps/mobile/modules/local-access-privacy/tests/PrivacyVisibilityStateTests.swift
new file mode 100644
index 0000000000..7fb625838c
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/PrivacyVisibilityStateTests.swift
@@ -0,0 +1,50 @@
+@main
+struct PrivacyVisibilityStateTests {
+ static func main() {
+ var checks = 0
+ func check(_ value: Bool, _ message: String) {
+ precondition(value, message)
+ checks += 1
+ }
+ var state = PrivacyVisibilityState()
+ check(!state.covered, "Disarmed content must retain its old visibility")
+ check(state.admitsAnnouncement(state.generation, gate: false), "Disarmed announcements must work")
+ check(!state.admitsForeground, "A missing native foreground must deny effects")
+ state.setForeground(true)
+ state.arm()
+ check(state.covered, "Arming must cover before authenticated content mounts")
+ check(!state.admitsForeground, "Foreground alone must not authorize armed effects")
+ check(!state.admitsAnnouncement(state.generation, gate: false), "Protected speech must stay hidden")
+ check(state.admitsAnnouncement(state.generation, gate: true), "Non-sensitive gate speech must remain usable")
+ let first = state.generation
+ check(state.publish(first), "Current access publication must reveal content")
+ check(!state.covered && state.admitsForeground, "Published content must admit effects")
+ check(state.admitsAnnouncement(first, gate: false), "Allowed content must permit speech")
+ state.setForeground(true)
+ check(state.generation == first && !state.covered, "An app dialog retaining foreground must not revoke visibility")
+ state.setForeground(false)
+ check(state.covered && !state.admitsForeground, "Inactivity must cover synchronously")
+ check(!state.admitsAnnouncement(first, gate: false), "Queued speech must fail after inactivity")
+ check(!state.admitsAnnouncement(state.generation, gate: true), "A passive cover must not announce a gate over a prompt")
+ check(!state.publish(state.generation), "Inactive publication must not expose content")
+ state.setForeground(true)
+ check(state.covered, "Native foreground must never uncover on its own")
+ check(!state.publish(first), "A stale visibility generation must never uncover")
+ check(state.publish(state.generation), "A fresh readiness handshake must restore visibility")
+ check(!state.admitsAnnouncement(first, gate: false), "Unlock must never replay stale speech")
+ let beforeOwnerChange = state.generation
+ state.cover()
+ check(!state.publish(beforeOwnerChange), "Owner revocation must invalidate old visibility")
+ state.fail()
+ check(state.covered && !state.publish(state.generation), "Native failure must remain protected")
+ check(!state.admitsForeground, "Native failure must deny effects")
+ check(state.admitsAnnouncement(state.generation, gate: true), "Recovery speech must remain non-sensitive")
+ let beforeDisarm = state.generation
+ state.disarm()
+ check(!state.covered && state.admitsForeground, "Unmounted content must release protection")
+ check(!state.admitsAnnouncement(beforeDisarm, gate: false), "Disarm must not deliver old account speech")
+ check(state.admitsAnnouncement(state.generation, gate: false), "Fresh disarmed speech must work")
+ check(!state.publish(state.generation), "Publication cannot arm an empty shell")
+ print("PrivacyVisibilityState Swift: \(checks) checks passed")
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Accessibility.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Accessibility.kt
new file mode 100644
index 0000000000..a38c6b581e
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Accessibility.kt
@@ -0,0 +1,18 @@
+package android.view.accessibility
+
+class AccessibilityEvent {
+ companion object {
+ const val TYPE_ANNOUNCEMENT = 1
+ fun obtain(type: Int): AccessibilityEvent {
+ check(type == TYPE_ANNOUNCEMENT)
+ return AccessibilityEvent()
+ }
+ }
+ var packageName = ""
+ var className = ""
+ val text = mutableListOf()
+}
+class AccessibilityManager {
+ val isEnabled = true
+ fun sendAccessibilityEvent(event: AccessibilityEvent) { check(event.text.isNotEmpty()) }
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Activities.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Activities.kt
new file mode 100644
index 0000000000..a8e1009cdd
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Activities.kt
@@ -0,0 +1,26 @@
+package android.app
+
+import android.os.Bundle
+import android.view.Window
+
+open class Activity { val window = Window() }
+class Dialog {
+ val window: Window? = Window()
+ fun create() = Unit
+}
+
+class Application {
+ interface ActivityLifecycleCallbacks {
+ fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?)
+ fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?)
+ fun onActivityResumed(activity: Activity)
+ fun onActivityPrePaused(activity: Activity)
+ fun onActivityPaused(activity: Activity)
+ fun onActivityStopped(activity: Activity)
+ fun onActivityStarted(activity: Activity)
+ fun onActivitySaveInstanceState(activity: Activity, outState: Bundle)
+ fun onActivityDestroyed(activity: Activity)
+ }
+ lateinit var callbacks: ActivityLifecycleCallbacks
+ fun registerActivityLifecycleCallbacks(value: ActivityLifecycleCallbacks) { callbacks = value }
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ApplicationWindowCover.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ApplicationWindowCover.kt
new file mode 100644
index 0000000000..aae1c9354c
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ApplicationWindowCover.kt
@@ -0,0 +1,38 @@
+package expo.modules.localaccessprivacy
+
+import android.app.Activity
+import android.view.Window
+
+// Observe coordinator outputs without claiming to render Android windows or a keyboard.
+internal class ApplicationWindowCover(
+ val window: Window,
+ val activity: Activity?,
+ activityWindow: Boolean,
+ onChange: () -> Unit,
+ onDetach: () -> Unit
+) {
+ init {
+ window.decorView.onChange = onChange
+ window.decorView.onDetach = onDetach
+ }
+ fun hasFocus() = window.decorView.hasWindowFocus() && window.decorView.isAttachedToWindow
+ fun resetGate() = Unit
+ fun apply(armed: Boolean, hide: Boolean, gate: PrivacyGate?, generation: Long, onAction: (Long, String) -> Unit) {
+ if (hide && !window.covered) {
+ window.inputFocused = false
+ window.focusClears += 1
+ }
+ window.covered = hide
+ window.gateVisible = hide && gate != null
+ }
+ fun restoreFocus() { window.inputFocused = true }
+ fun dispose() {
+ window.decorView.onChange = null
+ window.decorView.onDetach = null
+ window.covered = false
+ }
+}
+
+class PrivacyGateAction(val id: String, val enabled: Boolean = true)
+class PrivacyGate(val actions: List = emptyList())
+class LocalAccessPrivacyModule
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Bundle.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Bundle.kt
new file mode 100644
index 0000000000..03f8f11138
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Bundle.kt
@@ -0,0 +1,3 @@
+package android.os
+
+class Bundle
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Context.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Context.kt
new file mode 100644
index 0000000000..0302fbf385
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Context.kt
@@ -0,0 +1,12 @@
+package android.content
+
+import android.view.accessibility.AccessibilityManager
+
+open class Context {
+ companion object { const val ACCESSIBILITY_SERVICE = "accessibility" }
+ val packageName = "privacy.coordinator.test"
+ fun getSystemService(name: String): Any {
+ check(name == ACCESSIBILITY_SERVICE)
+ return AccessibilityManager()
+ }
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ExtraWindowEventListener.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ExtraWindowEventListener.kt
new file mode 100644
index 0000000000..0fcdaf1a1b
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ExtraWindowEventListener.kt
@@ -0,0 +1,8 @@
+package com.facebook.react.interfaces
+
+import android.view.Window
+
+interface ExtraWindowEventListener {
+ fun onExtraWindowCreate(window: Window)
+ fun onExtraWindowDestroy(window: Window)
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Fragments.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Fragments.kt
new file mode 100644
index 0000000000..8a9a334626
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Fragments.kt
@@ -0,0 +1,30 @@
+package androidx.fragment.app
+
+import android.app.Activity
+import android.app.Dialog
+import android.os.Bundle
+
+open class Fragment {
+ val childFragmentManager = FragmentManager()
+ var activity: Activity? = null
+ fun requireActivity() = requireNotNull(activity)
+}
+class DialogFragment : Fragment() {
+ val showsDialog = true
+ val dialog: Dialog? = Dialog()
+}
+class FragmentActivity : Activity() { val supportFragmentManager = FragmentManager() }
+class FragmentManager {
+ open class FragmentLifecycleCallbacks {
+ open fun onFragmentActivityCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) = Unit
+ open fun onFragmentStarted(fm: FragmentManager, f: Fragment) = Unit
+ open fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) = Unit
+ }
+ val fragments = mutableListOf()
+ val callbacks = mutableListOf()
+ fun registerFragmentLifecycleCallbacks(value: FragmentLifecycleCallbacks, recursive: Boolean) {
+ check(recursive)
+ callbacks.add(value)
+ }
+ fun unregisterFragmentLifecycleCallbacks(value: FragmentLifecycleCallbacks) { callbacks.remove(value) }
+}
diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/LocalAccessPrivacyCoordinatorTests.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/LocalAccessPrivacyCoordinatorTests.kt
new file mode 100644
index 0000000000..1a84fe785f
--- /dev/null
+++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/LocalAccessPrivacyCoordinatorTests.kt
@@ -0,0 +1,225 @@
+package expo.modules.localaccessprivacy
+
+import android.app.Activity
+import android.app.Application
+import android.view.Window
+import com.facebook.react.bridge.ReactContext
+
+private fun expect(value: Boolean, message: String) {
+ if (!value) throw AssertionError(message)
+}
+
+private class Fixture(private val application: Application) {
+ val activity = Activity()
+ val root = activity.window
+ val context = ReactContext(activity)
+ val events = mutableListOf