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>() + val generation get() = LocalAccessPrivacy.snapshot()["generation"] as Long + + init { + root.decorView.windowId?.isFocused = true + root.decorView.focus = true + application.callbacks.onActivityCreated(activity, null) + application.callbacks.onActivityResumed(activity) + LocalAccessPrivacy.attach(context) + LocalAccessPrivacy.arm() + expect(LocalAccessPrivacy.publish(generation), "Fixture must publish current access") + LocalAccessPrivacy.emit = { name, snapshot -> + if (name == "onVisibilityChange") events.add(snapshot) + } + } + + fun dialog(): Window { + val dialog = Window() + context.listeners.single().onExtraWindowCreate(dialog) + return dialog + } + + fun focus(dialog: Window) { + root.decorView.windowId?.isFocused = false + dialog.decorView.windowId?.isFocused = true + dialog.decorView.dispatchWindowFocus(true) + root.decorView.dispatchWindowFocus(false) + } + + fun dispose() { + LocalAccessPrivacy.emit = null + application.callbacks.onActivityDestroyed(activity) + LocalAccessPrivacy.disarm() + LocalAccessPrivacy.detach() + } +} + +fun main() { + val application = Application() + LocalAccessPrivacy.install(application) + val failures = mutableListOf() + var passed = 0 + fun test(name: String, body: (Fixture) -> Unit) { + val fixture = Fixture(application) + try { + body(fixture) + passed += 1 + println("PASS: $name") + } catch (error: AssertionError) { + failures.add("$name: ${error.message}") + } finally { + fixture.dispose() + } + } + + test("Activity loss before application dialog gain preserves published content") { fixture -> + val generation = fixture.generation + val clears = fixture.root.focusClears + val dialog = fixture.dialog() + // WindowManager has transferred ownership, but neither View has delivered the gain yet. + fixture.root.decorView.windowId?.isFocused = false + dialog.decorView.windowId?.isFocused = true + fixture.root.decorView.dispatchWindowFocus(false) + expect(!fixture.root.covered && !dialog.covered, "The callback gap must not cover application content") + expect(fixture.root.inputFocused && fixture.root.focusClears == clears, "The callback gap must not clear input focus") + expect(LocalAccessPrivacy.foregroundAllowed(), "The application transfer must retain admission") + dialog.decorView.dispatchWindowFocus(true) + expect(fixture.generation == generation && fixture.events.isEmpty(), "The transfer must not request a new handshake") + } + + test("Modal detachment before WindowManager removal preserves published content") { fixture -> + val dialog = fixture.dialog() + fixture.focus(dialog) + val generation = fixture.generation + val clears = fixture.root.focusClears + val nativeWindow = requireNotNull(dialog.decorView.windowId) + // React Native emits destruction before Dialog.dismiss, while the Modal still owns focus. + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + expect(!fixture.root.covered && !dialog.covered, "The pre-dismiss callback must not blank either window") + expect(LocalAccessPrivacy.foregroundAllowed(), "The attached Modal must retain native focus authority") + // ViewRootImpl detaches decor BEFORE mWindowSession.remove transfers native focus. + dialog.decorView.detach() + expect(!fixture.root.covered && fixture.root.inputFocused, "Detachment must preserve visible content and input focus") + expect(LocalAccessPrivacy.foregroundAllowed(), "The current native owner must bridge decor detachment") + expect(dialog.decorView.onChange == null && dialog.decorView.onDetach == null, "Detachment must remove the View listeners") + nativeWindow.isFocused = false + fixture.root.decorView.windowId?.isFocused = true + // WindowState removes the input token, so the old WindowId need not deliver a loss callback. + dialog.decorView.dispatchPostedActions() + expect(nativeWindow.observers.isEmpty(), "The post-removal checkpoint must release the native focus observer") + fixture.root.decorView.dispatchWindowFocus(true) + expect(fixture.generation == generation && fixture.root.focusClears == clears && fixture.events.isEmpty(), "Returning focus must preserve the publication") + } + + test("A pre-dismiss Modal still covers when a system prompt takes focus") { fixture -> + val dialog = fixture.dialog() + fixture.focus(dialog) + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + dialog.decorView.windowId?.isFocused = false + dialog.decorView.dispatchWindowFocus(false) + expect(fixture.root.covered && dialog.covered, "The still-attached Modal must remain protected") + expect(!LocalAccessPrivacy.foregroundAllowed(), "A destruction notice cannot override native focus loss") + } + + test("A detached Modal cannot preserve admission after native focus loss") { fixture -> + val dialog = fixture.dialog() + fixture.focus(dialog) + val nativeWindow = requireNotNull(dialog.decorView.windowId) + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + dialog.decorView.detach() + nativeWindow.isFocused = false + // Query before either native observer or View focus callbacks can deliver the loss. + expect(!LocalAccessPrivacy.foregroundAllowed() && fixture.root.covered, "A retained identity must query current native focus") + expect(nativeWindow.observers.isEmpty(), "A synchronous denial must release the dismissed observer") + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + expect(!LocalAccessPrivacy.foregroundAllowed(), "Repeated destruction must not restore authority") + } + + for (nativeCallback in listOf(false, true)) { + test("Post-detach focus loss protects attached windows (native callback: $nativeCallback)") { fixture -> + val remaining = fixture.dialog() + val dialog = fixture.dialog() + fixture.focus(dialog) + val nativeWindow = requireNotNull(dialog.decorView.windowId) + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + dialog.decorView.detach() + expect(!fixture.root.covered && !remaining.covered, "Detachment alone must not revoke publication") + nativeWindow.isFocused = false + if (nativeCallback) nativeWindow.dispatchFocusChange() else dialog.decorView.dispatchPostedActions() + expect(fixture.root.covered && remaining.covered, "A system prompt must cover without a detached View callback") + expect(!LocalAccessPrivacy.foregroundAllowed(), "Current native focus must deny prompt-time access") + expect(nativeWindow.observers.isEmpty(), "The dismissed native observer must be removed") + expect(remaining.decorView.onChange != null && remaining.decorView.onDetach != null, "Still-attached windows must retain their protection listeners") + } + } + + test("A rejected removal checkpoint fails closed") { fixture -> + val dialog = fixture.dialog() + fixture.focus(dialog) + dialog.decorView.acceptsPosts = false + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + dialog.decorView.detach() + expect(fixture.root.covered, "A failed native checkpoint must immediately protect attached content") + expect(fixture.events.lastOrNull()?.get("failed") == true, "The native failure must notify the shell after covering") + expect(!LocalAccessPrivacy.foregroundAllowed(), "A failed native checkpoint cannot admit effects") + expect(!LocalAccessPrivacy.publish(fixture.generation), "A current generation cannot bypass native failure") + } + + test("System prompt focus loss covers immediately with an application dialog open") { fixture -> + val dialog = fixture.dialog() + fixture.focus(dialog) + val generation = fixture.generation + LocalAccessPrivacy.setGate(generation, PrivacyGate(listOf(PrivacyGateAction("retry")))) + dialog.decorView.windowId?.isFocused = false + dialog.decorView.dispatchWindowFocus(false) + expect(fixture.root.covered && dialog.covered, "A system prompt must synchronously cover all application windows") + expect(!LocalAccessPrivacy.foregroundAllowed(), "A system prompt must deny application effects") + expect(!fixture.root.gateVisible && !dialog.gateVisible, "Application gates must stay passive over system prompts") + expect(!LocalAccessPrivacy.publish(generation), "Prompt focus loss must invalidate the old publication") + expect(!LocalAccessPrivacy.publish(fixture.generation), "A current generation cannot uncover while a prompt owns focus") + } + + for (detachBeforePause in listOf(false, true)) { + test("Activity pause denies access during dismissal (detached: $detachBeforePause)") { fixture -> + val dialog = fixture.dialog() + fixture.focus(dialog) + val nativeWindow = requireNotNull(dialog.decorView.windowId) + fixture.context.listeners.single().onExtraWindowDestroy(dialog) + if (detachBeforePause) dialog.decorView.detach() + application.callbacks.onActivityPrePaused(fixture.activity) + expect(fixture.root.covered && (detachBeforePause || dialog.covered), "Activity inactivity must not await a focus callback") + expect(!LocalAccessPrivacy.foregroundAllowed(), "A paused Activity cannot admit effects through its dialog") + if (!detachBeforePause) dialog.decorView.detach() + expect(fixture.root.covered && !LocalAccessPrivacy.foregroundAllowed(), "Detachment cannot override Activity inactivity") + expect(nativeWindow.observers.isEmpty(), "An inactive Activity must not retain a dismissed observer") + } + } + + test("Synchronous admission rejects native focus loss before the View callback") { fixture -> + fixture.root.decorView.windowId?.isFocused = false + expect(!LocalAccessPrivacy.foregroundAllowed(), "A stale View focus bit must not authorize effects") + expect(fixture.root.covered, "The synchronous native check must cover before returning denial") + } + + test("Missing native window identity fails closed despite cached View focus") { fixture -> + fixture.root.decorView.windowId = null + fixture.root.decorView.dispatchWindowFocus(true) + expect(fixture.root.covered && !LocalAccessPrivacy.foregroundAllowed(), "Missing native focus evidence must deny admission") + } + + test("Detached windows cannot preserve admission") { fixture -> + fixture.root.decorView.isAttachedToWindow = false + fixture.root.decorView.dispatchWindowFocus(false) + expect(fixture.root.covered && !LocalAccessPrivacy.foregroundAllowed(), "A detached window cannot authorize visibility") + } + + test("Returning from a system prompt requires a fresh visibility handshake") { fixture -> + val old = fixture.generation + fixture.root.decorView.windowId?.isFocused = false + fixture.root.decorView.dispatchWindowFocus(false) + fixture.root.decorView.windowId?.isFocused = true + fixture.root.decorView.dispatchWindowFocus(true) + expect(fixture.root.covered && !LocalAccessPrivacy.foregroundAllowed(), "Native focus gain alone must not uncover") + expect(!LocalAccessPrivacy.publish(old), "An old generation must remain rejected after focus returns") + expect(LocalAccessPrivacy.publish(fixture.generation) && !fixture.root.covered, "Only a fresh handshake can reveal content") + } + + println("LocalAccessPrivacy Android coordinator: $passed/14 cases passed") + check(failures.isEmpty()) { failures.joinToString("\n") } +} diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ReactContext.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ReactContext.kt new file mode 100644 index 0000000000..048a2806f1 --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ReactContext.kt @@ -0,0 +1,11 @@ +package com.facebook.react.bridge + +import android.app.Activity +import android.content.Context +import com.facebook.react.interfaces.ExtraWindowEventListener + +class ReactContext(val currentActivity: Activity?) : Context() { + val listeners = mutableListOf() + fun addExtraWindowEventListener(listener: ExtraWindowEventListener) { listeners.add(listener) } + fun removeExtraWindowEventListener(listener: ExtraWindowEventListener) { listeners.remove(listener) } +} diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ReactModalHostView.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ReactModalHostView.kt new file mode 100644 index 0000000000..517356babf --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/ReactModalHostView.kt @@ -0,0 +1,6 @@ +package com.facebook.react.views.modal + +import android.app.Dialog +import android.view.ViewGroup + +class ReactModalHostView : ViewGroup() { val dialog: Dialog? = Dialog() } diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Views.kt b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Views.kt new file mode 100644 index 0000000000..fcaa27fb48 --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/android/Views.kt @@ -0,0 +1,63 @@ +package android.view + +// Coordinator inputs only. WindowId models the native owner; focus models delivered View callbacks. +open class View { + var isAttachedToWindow = true + var windowId: WindowId? = WindowId() + var focus = false + var onChange: (() -> Unit)? = null + var onDetach: (() -> Unit)? = null + var acceptsPosts = true + private val posts = mutableListOf<() -> Unit>() + fun post(action: () -> Unit): Boolean { + if (!acceptsPosts) return false + // Only an attached View has the UI Handler used by the removal checkpoint. + if (isAttachedToWindow) posts.add(action) + return true + } + fun dispatchPostedActions() { + val pending = posts.toList() + posts.clear() + pending.forEach { it() } + } + fun detach() { + // View dispatches the listener before clearing AttachInfo; WindowManager removal follows later. + onDetach?.invoke() + isAttachedToWindow = false + windowId = null + } + fun hasWindowFocus() = focus + fun dispatchWindowFocus(value: Boolean) { + focus = value + onChange?.invoke() + } +} + +open class ViewGroup : View() { + val children = mutableListOf() + val childCount get() = children.size + fun getChildAt(index: Int) = children[index] +} + +class WindowId(var isFocused: Boolean = false) { + abstract class FocusObserver { + abstract fun onFocusGained(token: WindowId?) + abstract fun onFocusLost(token: WindowId?) + } + val observers = mutableSetOf() + fun registerFocusObserver(observer: FocusObserver) { check(observers.add(observer)) } + fun unregisterFocusObserver(observer: FocusObserver) { check(observers.remove(observer)) } + fun dispatchFocusChange() { + observers.toList().forEach { + if (isFocused) it.onFocusGained(this) else it.onFocusLost(this) + } + } +} + +class Window { + val decorView = ViewGroup() + var covered = false + var gateVisible = false + var inputFocused = true + var focusClears = 0 +} diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/ios/LocalAccessPrivacyCoordinatorTests.swift b/apps/mobile/modules/local-access-privacy/tests/coordinator/ios/LocalAccessPrivacyCoordinatorTests.swift new file mode 100644 index 0000000000..0fa72b2ab6 --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/ios/LocalAccessPrivacyCoordinatorTests.swift @@ -0,0 +1,138 @@ +import UIKit + +// Record rendering requests only. PrivacySceneWindow's real UIKit rendering needs device verification. +struct PrivacyGateAction { let id: String; let enabled: Bool } +struct PrivacyGate { let actions: [PrivacyGateAction] } +final class PrivacySceneWindow: UIWindow { + var gateVisible = false + func resetGate() {} + func update( + covered: Bool, interactive: Bool, level: CGFloat, gate: PrivacyGate?, generation: Int, + keyCandidate: UIWindow? = nil, onAction: @escaping (Int, String) -> Void + ) { + isHidden = !covered + gateVisible = covered && interactive && gate != nil + } +} + +private enum Transition: String, CaseIterable { + case activation, deactivation, connection, disconnection, applicationInactivity +} + +private final class Fixture { + let coordinator = LocalAccessPrivacy() + let scene = UIWindowScene() + let companion = UIWindowScene() + let window: UIWindow + let companionWindow: UIWindow + var events: [[String: Any]] = [] + var coveredAtEmission: [Bool] = [] + var generation: Int { coordinator.snapshot()["generation"] as! Int } + var companionGate: PrivacySceneWindow? { companion.windows.compactMap { $0 as? PrivacySceneWindow }.first } + + init(_ transition: Transition) { + window = UIWindow(windowScene: scene) + companionWindow = UIWindow(windowScene: companion) + window.isHidden = false + companionWindow.isHidden = false + window.isKeyWindow = true + companionWindow.isKeyWindow = true + if transition == .activation || transition == .connection { scene.activationState = .foregroundInactive } + let application = UIApplication.shared + application.applicationState = .active + application.connectedScenes = transition == .connection ? [companion] : [scene, companion] + application.windows = transition == .connection ? [companionWindow] : [window, companionWindow] + coordinator.arm() + precondition(coordinator.publish(generation), "Fixture must publish current access") + coordinator.emit = { [weak self] name, snapshot in + guard let self, name == "onVisibilityChange" else { return } + events.append(snapshot) + coveredAtEmission.append(companionWindow.accessibilityElementsHidden) + } + } + + func deliver(_ transition: Transition) { + let center = NotificationCenter.default + switch transition { + case .activation: + scene.activationState = .foregroundActive + center.post(name: UIScene.didActivateNotification, object: scene) + case .deactivation: + // UIKit still reports foregroundActive during willDeactivate. The coordinator must revoke now. + center.post(name: UIScene.willDeactivateNotification, object: scene) + case .connection: + center.post(name: UIScene.willConnectNotification, object: scene) + case .disconnection: + UIApplication.shared.connectedScenes.remove(scene) + window.isHidden = true + center.post(name: UIScene.didDisconnectNotification, object: scene) + case .applicationInactivity: + // The companion scene still reports active when the application callback arrives. + coordinator.applicationActive(false) + } + } + + func dispose() { + coordinator.emit = nil + NotificationCenter.default.removeObserver(coordinator) + coordinator.disarm() + UIApplication.shared.connectedScenes = [] + UIApplication.shared.windows = [] + } +} + +@main +struct LocalAccessPrivacyCoordinatorTests { + static func main() { + var failures: [String] = [] + var checks = 0 + func expect(_ value: Bool, _ message: String) { + checks += 1 + if !value { failures.append(message) } + } + for transition in Transition.allCases { + let fixture = Fixture(transition) + let coordinator = fixture.coordinator + let previous = fixture.generation + let gate = PrivacyGate(actions: [PrivacyGateAction(id: "retry", enabled: true)]) + expect(coordinator.setGate(previous, gate: gate), "\(transition): initial gate must be accepted") + fixture.deliver(transition) + // Check delivery before any snapshot read can refresh or notify the shell. + expect(fixture.events.count == 1, "\(transition): shell must receive exactly one visibility event") + expect(fixture.coveredAtEmission == [true], "\(transition): native coverage must precede shell notification") + expect(fixture.companionWindow.accessibilityElementsHidden, "\(transition): cover the companion before returning from the callback") + let snapshot = coordinator.snapshot() + let current = fixture.generation + let active = transition != .applicationInactivity + expect(current > previous, "\(transition): lifecycle transition must revoke the old generation") + expect(snapshot["foreground"] as? Bool == active, "\(transition): aggregate activity must match the native scene inputs") + expect(fixture.events.first?["generation"] as? Int == current, "\(transition): shell must receive the latest generation") + expect(fixture.companionGate?.gateVisible == false, "\(transition): the old gate must not remain interactive") + expect(!coordinator.foregroundAllowed(), "\(transition): revoked visibility must deny admission") + expect(!coordinator.publish(previous), "\(transition): stale publication must fail") + expect(!coordinator.setGate(previous, gate: gate), "\(transition): a stale gate must fail") + expect(fixture.events.count == 1, "\(transition): reads and rejected handshakes must not duplicate the notification") + if active { + expect(coordinator.setGate(current, gate: gate), "\(transition): the notified generation must accept a fresh gate") + expect(fixture.companionGate?.gateVisible == true, "\(transition): the companion must regain its current gate") + expect(coordinator.publish(current), "\(transition): the notified generation must permit a fresh handshake") + expect(!fixture.companionWindow.accessibilityElementsHidden, "\(transition): a fresh handshake must restore companion content") + } else { + expect(!coordinator.publish(current), "Application inactivity must reject even a current publication") + expect(coordinator.setGate(current, gate: gate), "The shell can prepare a current gate while inactive") + expect(fixture.companionGate?.gateVisible == false, "An inactive gate must remain passive for the native prompt") + coordinator.applicationActive(true) + let returned = fixture.generation + expect(fixture.events.count == 2, "Application return must deliver one new generation") + expect(fixture.events.last?["generation"] as? Int == returned, "Application return must notify the current generation") + expect(!coordinator.publish(current), "The inactive generation must not authorize foreground return") + expect(fixture.companionWindow.accessibilityElementsHidden, "Foreground return alone must keep content covered") + expect(coordinator.publish(returned), "Foreground return requires a fresh handshake") + } + fixture.dispose() + } + print("LocalAccessPrivacy iOS coordinator: \(checks - failures.count)/\(checks) checks passed across 5 transitions") + failures.forEach { print("FAIL: \($0)") } + if !failures.isEmpty { exit(1) } + } +} diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/ios/UIKit.swift b/apps/mobile/modules/local-access-privacy/tests/coordinator/ios/UIKit.swift new file mode 100644 index 0000000000..f977600197 --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/ios/UIKit.swift @@ -0,0 +1,93 @@ +// Test-only UIKit inputs for executing the real coordinator on the host. No rendering or OS timing is modeled. +@_exported import Foundation +@_exported import CoreGraphics + +public struct UIColor { public static let systemBackground = UIColor() } + +open class UIView: NSObject { + public struct AutoresizingMask: OptionSet { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + public static let flexibleWidth = AutoresizingMask(rawValue: 1) + public static let flexibleHeight = AutoresizingMask(rawValue: 2) + } + public var frame: CGRect + public var bounds: CGRect { frame } + public var backgroundColor: UIColor? + public var isOpaque = false + public var isAccessibilityElement = false + public var accessibilityElementsHidden = false + public var isHidden = false + public var isFirstResponder = false + public var autoresizingMask: AutoresizingMask = [] + public weak var superview: UIView? + public private(set) var subviews: [UIView] = [] + public var window: UIWindow? { (self as? UIWindow) ?? superview?.window } + public init(frame: CGRect = .zero) { self.frame = frame; super.init() } + public static func performWithoutAnimation(_ body: () -> Void) { body() } + public func addSubview(_ view: UIView) { view.removeFromSuperview(); subviews.append(view); view.superview = self } + public func removeFromSuperview() { superview?.subviews.removeAll { $0 === self }; superview = nil } + public func bringSubviewToFront(_ view: UIView) { subviews.removeAll { $0 === view }; subviews.append(view) } + @discardableResult public func endEditing(_ force: Bool) -> Bool { + isFirstResponder = false + subviews.forEach { $0.endEditing(force) } + return true + } + @discardableResult public func becomeFirstResponder() -> Bool { isFirstResponder = true; return true } +} + +public final class UIViewController: NSObject { + public var presentedViewController: UIViewController? + public let view = UIView() +} + +public class UIScene: NSObject { + public enum ActivationState { case foregroundActive, foregroundInactive, background } + public var activationState = ActivationState.foregroundActive + public static let willDeactivateNotification = Notification.Name("sceneWillDeactivate") + public static let willConnectNotification = Notification.Name("sceneWillConnect") + public static let didActivateNotification = Notification.Name("sceneDidActivate") + public static let didDisconnectNotification = Notification.Name("sceneDidDisconnect") +} + +public final class UIWindowScene: UIScene { public var windows: [UIWindow] = [] } +public final class UIScreen: NSObject { + public static let main = UIScreen() + public let bounds = CGRect(x: 0, y: 0, width: 400, height: 800) +} + +open class UIWindow: UIView { + public struct Level { + public let rawValue: CGFloat + public init(rawValue: CGFloat) { self.rawValue = rawValue } + public static let normal = Level(rawValue: 0) + } + public static let didBecomeVisibleNotification = Notification.Name("windowVisible") + public static let didBecomeHiddenNotification = Notification.Name("windowHidden") + public static let didBecomeKeyNotification = Notification.Name("windowKey") + public var isKeyWindow = false + public var windowLevel = Level.normal + public var rootViewController: UIViewController? + public var screen = UIScreen.main + public weak var windowScene: UIWindowScene? + public override init(frame: CGRect) { super.init(frame: frame); isHidden = true } + public init(windowScene: UIWindowScene) { + self.windowScene = windowScene + super.init() + isHidden = true + windowScene.windows.append(self) + } +} + +public final class UIApplication: NSObject { + public enum State { case active, inactive, background } + public static let shared = UIApplication() + public var applicationState = State.active + public var connectedScenes = Set() + public var windows: [UIWindow] = [] +} + +public enum UIAccessibility { + public enum Notification { case screenChanged } + public static func post(notification: Notification, argument: Any?) {} +} diff --git a/apps/mobile/modules/local-access-privacy/tests/coordinator/run.sh b/apps/mobile/modules/local-access-privacy/tests/coordinator/run.sh new file mode 100644 index 0000000000..2a3eeb4c27 --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/coordinator/run.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run from apps/mobile, with an existing output directory outside the product worktree. +# ios: bash modules/local-access-privacy/tests/coordinator/run.sh ios +# android: bash modules/local-access-privacy/tests/coordinator/run.sh android +# These adapters execute the production coordinators, not platform rendering or OS window timing. +set -euo pipefail +platform=${1:?platform is required} +output=${2:?output directory is required} +test -d "$output" +suite=$(dirname "$0") +module="$suite/../.." +case "$platform" in + ios) + swiftc -emit-module -emit-library -module-name UIKit "$suite/ios/UIKit.swift" \ + -emit-module-path "$output/UIKit.swiftmodule" -o "$output/libUIKit.dylib" + swiftc -I "$output" -L "$output" -lUIKit -Xlinker -rpath -Xlinker "$output" \ + "$module/ios/PrivacyVisibilityState.swift" "$module/ios/LocalAccessPrivacy.swift" \ + "$suite/ios/LocalAccessPrivacyCoordinatorTests.swift" -o "$output/coordinator-tests" + "$output/coordinator-tests" + ;; + android) + runtime=${3:?kotlin-stdlib.jar is required} + shift 3 + : "${1:?kotlinc command is required}" + javac -d "$output" "$module/android/src/main/java/expo/modules/localaccessprivacy/PrivacyVisibilityState.java" + "$@" -no-stdlib -no-reflect -classpath "$output:$runtime" -d "$output" \ + "$module/android/src/main/java/expo/modules/localaccessprivacy/LocalAccessPrivacy.kt" "$suite"/android/*.kt + java -cp "$output:$runtime" expo.modules.localaccessprivacy.LocalAccessPrivacyCoordinatorTestsKt + ;; + *) + printf 'Unsupported coordinator platform: %s\n' "$platform" >&2 + exit 2 + ;; +esac diff --git a/apps/mobile/modules/local-access-privacy/tests/native-test-helpers.ts b/apps/mobile/modules/local-access-privacy/tests/native-test-helpers.ts new file mode 100644 index 0000000000..16bfa402da --- /dev/null +++ b/apps/mobile/modules/local-access-privacy/tests/native-test-helpers.ts @@ -0,0 +1,96 @@ +import { type LocalAccessPrivacySnapshot } from '../../../src/lib/local-access-privacy'; + +type NativeTestState = { + available: boolean; + nativeFailure: boolean; + secure: boolean; + captureFailure: boolean; + captureWait: Promise | undefined; + captureEvents: string[]; + snapshot: { + -readonly [Key in keyof LocalAccessPrivacySnapshot]: LocalAccessPrivacySnapshot[Key]; + }; + delivered: string[]; + queue: (() => void)[]; + listeners: Map void>; +}; + +/** Deterministic native queue adapter. The Swift/Java suites execute the actual visibility reducers. */ +export function createPrivacyNativeTestModule(adapter: NativeTestState) { + if (!adapter.available) { + throw new Error('Missing native privacy'); + } + return { + arm: () => { + adapter.snapshot.armed = true; + adapter.snapshot.covered = true; + adapter.snapshot.generation += 1; + }, + disarm: () => { + adapter.snapshot.armed = false; + adapter.snapshot.covered = false; + adapter.snapshot.generation += 1; + }, + cover: () => { + adapter.snapshot.covered = adapter.snapshot.armed; + adapter.snapshot.generation += 1; + }, + getSnapshot: () => ({ ...adapter.snapshot }), + publishVisibility: (generation: number) => { + if (generation !== adapter.snapshot.generation || !adapter.snapshot.foreground) { + return false; + } + adapter.snapshot.covered = false; + return true; + }, + isForegroundAllowed: () => { + if (adapter.nativeFailure) { + throw new Error('Native failure'); + } + return adapter.snapshot.foreground && !adapter.snapshot.covered; + }, + announce: async (message: string, generation: number, gate: boolean) => { + const result = await new Promise(resolve => { + adapter.queue.push(() => { + const allowed = + generation === adapter.snapshot.generation && + (!adapter.snapshot.armed || + (adapter.snapshot.foreground && (!adapter.snapshot.covered || gate))); + if (allowed) { + adapter.delivered.push(message); + } + resolve(allowed); + }); + }); + return result; + }, + addListener: (name: string, listener: (event: never) => void) => { + adapter.listeners.set(name, listener); + return { remove: () => adapter.listeners.delete(name) }; + }, + }; +} + +/** Fake only the capture native boundary; Expo retains ownership of its real key set. */ +export function createCaptureNativeTestModule(adapter: NativeTestState) { + return { + UnavailabilityError: Error, + requireNativeModule: () => ({ + preventScreenCapture: async () => { + adapter.captureEvents.push('prevent'); + if (adapter.captureWait) { + await adapter.captureWait; + } + if (adapter.captureFailure) { + throw new Error('Capture unavailable'); + } + adapter.secure = true; + }, + allowScreenCapture: async () => { + adapter.captureEvents.push('allow'); + await Promise.resolve(); + adapter.secure = false; + }, + }), + }; +} diff --git a/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx b/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx index 857c4d6583..04fb87a3ed 100644 --- a/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-message-list.mounted.test.tsx @@ -1,10 +1,36 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts the React Native tree without a device */ +import { type KiloChatClient, type Message } from '@kilocode/kilo-chat'; import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrivacyNativeTestModule } from '../../../modules/local-access-privacy/tests/native-test-helpers'; +import { MessageList } from '../kilo-chat/message-list'; import { SessionMessageList } from './session-message-list'; +const adapter = vi.hoisted((): Parameters[0] => ({ + available: true, + nativeFailure: false, + secure: false, + captureFailure: false, + captureWait: undefined, + captureEvents: [], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + delivered: [], + queue: [], + listeners: new Map(), +})); +vi.mock('expo', () => ({ + requireNativeModule: () => createPrivacyNativeTestModule(adapter), +})); +vi.mock('expo-screen-capture', () => ({ + allowScreenCaptureAsync: vi.fn(), + preventScreenCaptureAsync: vi.fn(), +})); +vi.mock('expo-crypto', () => ({ getRandomValues: vi.fn() })); +vi.mock('@kilocode/kilo-chat-hooks', () => ({ pendingActionGroupIdForMessage: () => null })); +vi.mock('@/components/kilo-chat/message-bubble', () => ({ MessageBubble: () => null })); + const flashListProps = vi.hoisted(() => ({ current: null as Record | null })); vi.mock('@shopify/flash-list', () => ({ @@ -14,7 +40,14 @@ vi.mock('@shopify/flash-list', () => ({ }, })); vi.mock('react-native', () => ({ - AccessibilityInfo: { announceForAccessibility: vi.fn() }, + AccessibilityInfo: { + // Record bypasses as delivered speech, not as a separate mock call. + announceForAccessibility: (message: string) => { + adapter.delivered.push(message); + }, + }, + Keyboard: { addListener: () => ({ remove: () => undefined }) }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', View: 'View', })); @@ -45,10 +78,88 @@ vi.mock('@/components/agents/session-pagination-header', () => ({ SessionPaginationHeader: () => null, })); +let renderer: ReturnType | undefined = undefined; + +beforeEach(() => { + vi.useFakeTimers(); + adapter.available = true; + adapter.snapshot = { + generation: 0, + armed: false, + foreground: true, + covered: false, + failed: false, + }; + adapter.delivered = []; + adapter.queue = []; +}); + +afterEach(() => { + act(() => { + renderer?.unmount(); + }); + renderer = undefined; + vi.useRealTimers(); +}); + +function deliver() { + for (const task of adapter.queue.splice(0)) { + task(); + } +} + +const keyExtractor = (item: string) => item; +const unusedClient = {}; + +function updateList(kind: 'session' | 'kilo-chat', items: string[], owner = 'session-1') { + const messages = items.map(id => ({ + id, + senderId: 'bot-1', + content: [], + inReplyToMessageId: null, + replyTo: null, + updatedAt: 10, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + })); + const element = + kind === 'session' + ? createElement(SessionMessageList, { + sessionId: owner, + items, + keyExtractor, + hasOlderMessages: true, + isLoadingOlderMessages: false, + olderMessagesError: null, + olderMessagesOmittedItemCount: 0, + onLoadOlderMessages: () => undefined, + renderItem: () => null, + }) + : createElement(MessageList, { + client: unusedClient as KiloChatClient, + conversationId: owner, + messages, + currentUserId: 'user-1', + pendingAction: null, + scrollToNewestRequest: 0, + onExecuteAction: () => undefined, + onReactionPress: () => undefined, + }); + act(() => { + if (renderer) { + renderer.update(element); + } else { + renderer = TestRenderer.create(element); + } + }); +} + describe('SessionMessageList', () => { it('disables clipped subviews to avoid Android Fabric reattachment races', () => { act(() => { - TestRenderer.create( + renderer = TestRenderer.create( createElement(SessionMessageList, { sessionId: 'session-1', items: ['message-1'], @@ -66,3 +177,87 @@ describe('SessionMessageList', () => { expect(flashListProps.current?.removeClippedSubviews).toBe(false); }); }); + +describe.each(['session', 'kilo-chat'] as const)('%s older-message announcements', kind => { + it.each([false, true])('announces a prepend once through native delivery (armed: %s)', armed => { + adapter.snapshot.armed = armed; + updateList(kind, ['newest']); + expect(adapter.queue).toEqual([]); + + updateList(kind, ['older', 'newest']); + expect(adapter.delivered).toEqual([]); + expect(adapter.queue).toHaveLength(1); + deliver(); + expect(adapter.delivered).toEqual(['Earlier messages loaded']); + + updateList(kind, ['older', 'newest']); + deliver(); + expect(adapter.delivered).toEqual(['Earlier messages loaded']); + }); + + it('stays silent for empty, initial, unchanged, appended, and replacement transcripts', () => { + updateList(kind, []); + updateList(kind, ['newest']); + updateList(kind, ['newest']); + updateList(kind, ['newest', 'latest']); + updateList(kind, ['older', 'newest', 'latest'], 'replacement-session'); + deliver(); + + expect(adapter.delivered).toEqual([]); + }); + + it.each([ + { state: 'cancelled unlock', available: true, failed: false }, + { state: 'native failure', available: true, failed: true }, + { state: 'missing native module', available: false, failed: false }, + ])('retains messages but drops speech after $state without replay', state => { + adapter.available = state.available; + adapter.snapshot = { + generation: 1, + armed: true, + foreground: true, + covered: true, + failed: state.failed, + }; + updateList(kind, ['newest']); + updateList(kind, ['older', 'newest']); + deliver(); + expect(adapter.delivered).toEqual([]); + expect(flashListProps.current?.data).toHaveLength(2); + + adapter.available = true; + adapter.snapshot = { ...adapter.snapshot, generation: 2, covered: false, failed: false }; + updateList(kind, ['older', 'newest']); + deliver(); + expect(adapter.delivered).toEqual([]); + + updateList(kind, ['oldest', 'older', 'newest']); + deliver(); + expect(adapter.delivered).toEqual(['Earlier messages loaded']); + }); + + it.each(['covered', 'unlocked', 'owner replaced'] as const)( + 'rejects queued speech when native delivery runs %s', + deliveryState => { + adapter.snapshot.armed = true; + updateList(kind, ['newest']); + updateList(kind, ['older', 'newest']); + expect(adapter.queue).toHaveLength(1); + expect(adapter.delivered).toEqual([]); + + adapter.snapshot = { ...adapter.snapshot, generation: 1, foreground: false, covered: true }; + if (deliveryState !== 'covered') { + adapter.snapshot = { ...adapter.snapshot, generation: 2, foreground: true, covered: false }; + } + if (deliveryState === 'owner replaced') { + updateList(kind, ['replacement-message'], 'replacement-session'); + } + deliver(); + expect(adapter.delivered).toEqual([]); + + adapter.snapshot = { ...adapter.snapshot, generation: 2, foreground: true, covered: false }; + deliver(); + expect(adapter.delivered).toEqual([]); + } + ); +}); diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index cbb4abc8f7..17b0aee943 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -2,10 +2,11 @@ import { FlashList, type ListRenderItem } from '@shopify/flash-list'; import { type OlderMessagesError } from '@kilocode/cloud-agent-sdk'; import { ChevronDown } from '@/components/ui/icons'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { AccessibilityInfo, Pressable, View, type ViewStyle } from 'react-native'; +import { Pressable, View, type ViewStyle } from 'react-native'; import { useTranslation } from 'react-i18next'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { announceForA11y } from '@/lib/a11y/announce'; import { useSessionListAutoScroll } from '@/components/agents/use-session-list-auto-scroll'; import { SessionPaginationHeader } from '@/components/agents/session-pagination-header'; import { shouldTriggerOlderMessagesLoad } from '@/components/agents/session-message-list-state'; @@ -162,7 +163,7 @@ export function SessionMessageList({ nextNewestKey, }) ) { - AccessibilityInfo.announceForAccessibility(getOlderMessagesArrivedAnnouncement()); + announceForA11y(getOlderMessagesArrivedAnnouncement()); } olderArrivalInitializedRef.current = true; olderArrivalCountRef.current = nextCount; diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index f573b3611f..dc5440279f 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -3,7 +3,6 @@ import { type ExecApprovalDecision, type KiloChatClient, type Message } from '@k import { type PendingAction, pendingActionGroupIdForMessage } from '@kilocode/kilo-chat-hooks'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { - AccessibilityInfo, Keyboard, type NativeScrollEvent, type NativeSyntheticEvent, @@ -11,6 +10,7 @@ import { type ViewStyle, } from 'react-native'; +import { announceForA11y } from '@/lib/a11y/announce'; import { MessageBubble } from '@/components/kilo-chat/message-bubble'; import { getOlderMessagesArrivedAnnouncement, @@ -188,7 +188,7 @@ export function MessageList({ nextNewestKey, }) ) { - AccessibilityInfo.announceForAccessibility(getOlderMessagesArrivedAnnouncement()); + announceForA11y(getOlderMessagesArrivedAnnouncement()); } olderArrivalInitializedRef.current = true; olderArrivalCountRef.current = nextCount; diff --git a/apps/mobile/src/lib/a11y/announce.test.ts b/apps/mobile/src/lib/a11y/announce.test.ts index 57fe199db9..f170c02782 100644 --- a/apps/mobile/src/lib/a11y/announce.test.ts +++ b/apps/mobile/src/lib/a11y/announce.test.ts @@ -1,42 +1,98 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type Component, type RefObject } from 'react'; +import { createPrivacyNativeTestModule } from '../../../modules/local-access-privacy/tests/native-test-helpers'; +import { announceLocalAccessPrivacy } from '@/lib/local-access-privacy'; import { announceForA11y, moveA11yFocus } from './announce'; +const adapter = vi.hoisted((): Parameters[0] => ({ + available: true, + nativeFailure: false, + secure: false, + captureFailure: false, + captureWait: undefined, + captureEvents: [], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + delivered: [], + queue: [], + listeners: new Map(), +})); +const nativeAnnounce = vi.hoisted(() => + vi.fn['announce']>() +); const accessibilityMock = vi.hoisted(() => ({ - announceForAccessibility: vi.fn(), - setAccessibilityFocus: vi.fn(), + announceForAccessibility: vi.fn<(message: string) => void>(), + setAccessibilityFocus: vi.fn<(node: number) => void>(), + focusedNode: null as number | null, })); - const findNodeHandleMock = vi.hoisted(() => vi.fn<(node: unknown) => number | null>(() => 42)); +vi.mock('expo', () => ({ + requireNativeModule: () => ({ + ...createPrivacyNativeTestModule(adapter), + announce: nativeAnnounce, + }), +})); +vi.mock('expo-screen-capture', () => ({ + allowScreenCaptureAsync: vi.fn(), + preventScreenCaptureAsync: vi.fn(), +})); vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, AccessibilityInfo: accessibilityMock, findNodeHandle: findNodeHandleMock, })); -describe('announceForA11y', () => { - beforeEach(() => { - accessibilityMock.announceForAccessibility.mockClear(); +function deliver() { + for (const task of adapter.queue.splice(0)) { + task(); + } +} + +beforeEach(() => { + adapter.available = true; + adapter.snapshot = { + generation: 0, + armed: false, + foreground: true, + covered: false, + failed: false, + }; + adapter.delivered = []; + adapter.queue = []; + nativeAnnounce.mockReset().mockImplementation(createPrivacyNativeTestModule(adapter).announce); + // Record unguarded delivery too, so a direct React Native bypass cannot pass. + accessibilityMock.announceForAccessibility.mockReset().mockImplementation(message => { + adapter.delivered.push(message); }); - - afterEach(() => { - vi.clearAllMocks(); + accessibilityMock.focusedNode = null; + accessibilityMock.setAccessibilityFocus.mockReset().mockImplementation(node => { + accessibilityMock.focusedNode = node; }); + findNodeHandleMock.mockReset().mockReturnValue(42); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); - it('forwards non-empty messages to AccessibilityInfo', () => { +describe('announceForA11y', () => { + it.each([false, true])('delivers allowed speech through the native queue (armed: %s)', armed => { + adapter.snapshot.armed = armed; announceForA11y('Agent needs your input'); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledTimes(1); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith( - 'Agent needs your input' - ); + expect(adapter.delivered).toEqual([]); + expect(adapter.queue).toHaveLength(1); + deliver(); + expect(adapter.delivered).toEqual(['Agent needs your input']); + expect(adapter.snapshot.armed).toBe(armed); }); it('trims surrounding whitespace before announcing', () => { announceForA11y(' Permission required '); + deliver(); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith('Permission required'); + expect(adapter.delivered).toEqual(['Permission required']); }); it('drops empty and whitespace-only messages', () => { @@ -44,31 +100,115 @@ describe('announceForA11y', () => { announceForA11y(' '); announceForA11y('\n\t'); - expect(accessibilityMock.announceForAccessibility).not.toHaveBeenCalled(); + expect(adapter.queue).toEqual([]); + deliver(); + expect(adapter.delivered).toEqual([]); }); - it('swallows a throwing native announcement so the caller flow continues', () => { - accessibilityMock.announceForAccessibility.mockImplementationOnce(() => { - throw new Error('native announcement failed'); - }); - - expect(() => { - announceForA11y('Session deleted'); - }).not.toThrow(); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith('Session deleted'); + it.each(['throw', 'rejection', 'missing module'] as const)( + 'preserves the caller flow after native %s without replaying failed speech', + async failure => { + if (failure === 'throw') { + nativeAnnounce.mockImplementationOnce(() => { + throw new Error('native announcement failed'); + }); + } else if (failure === 'rejection') { + nativeAnnounce.mockRejectedValueOnce(new Error('native announcement failed')); + } else { + adapter.available = false; + } + + expect(() => { + announceForA11y('Session deleted'); + }).not.toThrow(); + await Promise.resolve(); + expect(adapter.delivered).toEqual([]); + + adapter.available = true; + deliver(); + expect(adapter.delivered).toEqual([]); + announceForA11y('New session created'); + deliver(); + expect(adapter.delivered).toEqual(['New session created']); + } + ); + + it.each([ + { state: 'inactivity', foreground: false, failed: false }, + { state: 'cancelled authentication', foreground: true, failed: false }, + { state: 'native failure', foreground: true, failed: true }, + ])('suppresses protected speech during $state without replay after unlock', state => { + adapter.snapshot = { + generation: 1, + armed: true, + foreground: state.foreground, + covered: true, + failed: state.failed, + }; + announceForA11y('Secret transcript'); + expect(adapter.queue).toEqual([]); + deliver(); + expect(adapter.delivered).toEqual([]); + + adapter.snapshot = { + ...adapter.snapshot, + generation: 2, + foreground: true, + covered: false, + failed: false, + }; + deliver(); + expect(adapter.delivered).toEqual([]); + announceForA11y('Fresh status'); + deliver(); + expect(adapter.delivered).toEqual(['Fresh status']); }); -}); -describe('moveA11yFocus', () => { - beforeEach(() => { - accessibilityMock.setAccessibilityFocus.mockClear(); - findNodeHandleMock.mockClear(); - }); + it.each(['covered', 'unlocked'] as const)( + 'rejects native-queued speech across inactivity when delivery runs %s', + deliveryState => { + adapter.snapshot.armed = true; + announceForA11y('Queued secret'); + expect(adapter.queue).toHaveLength(1); + expect(adapter.delivered).toEqual([]); + + // Only native state changes: no JavaScript lifecycle event authorizes this delivery. + adapter.snapshot = { ...adapter.snapshot, generation: 1, foreground: false, covered: true }; + if (deliveryState === 'unlocked') { + adapter.snapshot = { ...adapter.snapshot, generation: 2, foreground: true, covered: false }; + } + deliver(); + expect(adapter.delivered).toEqual([]); + + adapter.snapshot = { ...adapter.snapshot, generation: 2, foreground: true, covered: false }; + deliver(); + expect(adapter.delivered).toEqual([]); + announceForA11y('Fresh status'); + deliver(); + expect(adapter.delivered).toEqual(['Fresh status']); + } + ); + + it('keeps non-sensitive gate speech and focus separate from protected speech', async () => { + adapter.snapshot.armed = true; + adapter.snapshot.covered = true; + findNodeHandleMock.mockReturnValueOnce(7); + const gateRef: RefObject = { + current: { node: 'gate' } as unknown as Component, + }; + expect(moveA11yFocus(gateRef)).toBe(true); + + const gateSpeech = announceLocalAccessPrivacy('Unlock required', 'gate'); + announceForA11y('Secret transcript'); + deliver(); - afterEach(() => { - vi.clearAllMocks(); + expect(await gateSpeech).toBe(true); + expect(adapter.delivered).toEqual(['Unlock required']); + expect(accessibilityMock.focusedNode).toBe(7); }); +}); +describe('moveA11yFocus', () => { it('returns false when the ref has no mounted node', () => { findNodeHandleMock.mockReturnValueOnce(null); const ref: RefObject = { current: null }; diff --git a/apps/mobile/src/lib/a11y/announce.ts b/apps/mobile/src/lib/a11y/announce.ts index 1bfe1bba32..960bdf6727 100644 --- a/apps/mobile/src/lib/a11y/announce.ts +++ b/apps/mobile/src/lib/a11y/announce.ts @@ -1,13 +1,12 @@ import { AccessibilityInfo, findNodeHandle } from 'react-native'; import { type Component, type RefObject } from 'react'; -// Shared accessibility helpers used across mobile screens. These wrap -// `react-native` primitives so call sites stay small and so unit tests can -// target a single import surface (rather than mocking `react-native` -// per-feature). The functions are intentionally side-effecting and best -// effort — they never throw on missing handles or native accessibility -// failures, so a TalkBack/VoiceOver outage never breaks the UI or a caller's -// completion flow. +import { announceLocalAccessPrivacy } from '@/lib/local-access-privacy'; + +// Shared accessibility helpers keep native delivery and focus handling in one +// place. Native privacy checks protected speech again after queueing; denied +// announcements are never replayed. Accessibility failures must not break UI +// completion flows. /** * Announce a message to assistive technologies (TalkBack on Android, @@ -22,7 +21,7 @@ export function announceForA11y(message: string): void { return; } try { - AccessibilityInfo.announceForAccessibility(trimmed); + void announceLocalAccessPrivacy(trimmed); } catch { // Best effort: a native accessibility outage must never break the UI. } diff --git a/apps/mobile/src/lib/a11y/announcement-producer-inventory.test.ts b/apps/mobile/src/lib/a11y/announcement-producer-inventory.test.ts new file mode 100644 index 0000000000..9a09a8588a --- /dev/null +++ b/apps/mobile/src/lib/a11y/announcement-producer-inventory.test.ts @@ -0,0 +1,216 @@ +/* eslint-disable import/no-nodejs-modules -- this inventory reads application source, not device data */ +import { readdirSync, readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +type Counts = Record; + +const directNativeMethods = new Set([ + 'announceForAccessibility', + 'announceForAccessibilityWithOptions', + 'sendAccessibilityEvent', +]); + +function announcements(text: string): Counts { + const source = ts.createSourceFile( + 'announcement.tsx', + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const aliases = new Map(); + const counts: Counts = {}; + const add = (kind: string) => { + counts[kind] = (counts[kind] ?? 0) + 1; + }; + function nameOf(node: ts.Node): string { + if (ts.isIdentifier(node)) { + return aliases.get(node.text) ?? node.text; + } + if (ts.isStringLiteralLike(node)) { + return node.text; + } + if (ts.isPropertyAccessExpression(node)) { + return ['call', 'apply', 'bind'].includes(node.name.text) + ? nameOf(node.expression) + : node.name.text; + } + if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) { + return node.argumentExpression.text; + } + if ( + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isNonNullExpression(node) + ) { + return nameOf(node.expression); + } + return ''; + } + function bind(local: string, imported: string) { + aliases.set(local, imported); + if (directNativeMethods.has(imported)) { + add('unguarded-native'); + } + } + function visit(node: ts.Node) { + if (ts.isImportSpecifier(node)) { + bind(node.name.text, node.propertyName?.text ?? node.name.text); + } + if (ts.isVariableDeclaration(node) && node.initializer) { + if (ts.isIdentifier(node.name)) { + aliases.set(node.name.text, nameOf(node.initializer)); + } else if (ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + if (ts.isIdentifier(element.name)) { + bind(element.name.text, nameOf(element.propertyName ?? element.name)); + } + } + } + } + // Detect native method references too: aliases and callback passing cannot hide a bypass. + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + if (directNativeMethods.has(nameOf(node))) { + add('unguarded-native'); + } else if ( + ts.isElementAccessExpression(node) && + !ts.isStringLiteralLike(node.argumentExpression) && + nameOf(node.expression) === 'AccessibilityInfo' + ) { + add('unguarded-native'); + } + } + if (ts.isCallExpression(node)) { + const name = nameOf(node.expression); + if (name === 'announceForA11y') { + add('protected-helper'); + } else if (name === 'announceLocalAccessPrivacy') { + add(node.arguments.length === 1 ? 'protected-native-adapter' : 'explicit-native-kind'); + } else if (name === 'announce') { + add('announcement-dispatch'); + } + } + if ( + (ts.isPropertyAssignment(node) && nameOf(node.initializer) === 'announceForA11y') || + (ts.isShorthandPropertyAssignment(node) && nameOf(node.name) === 'announceForA11y') + ) { + add('protected-callback'); + } + ts.forEachChild(node, visit); + } + visit(source); + return counts; +} + +// Every application producer enters the protected helper, including injected blocking-card callbacks. +// The sole native dispatch belongs to the generation-fenced adapter; native suites verify its final guard. +const inventory: Record = { + 'app/_layout.tsx': { 'protected-helper': 1 }, + 'components/agents/blocking-card-state.ts': { 'announcement-dispatch': 1 }, + 'components/agents/permission-card.tsx': { 'protected-callback': 1 }, + 'components/agents/question-card.tsx': { 'protected-callback': 1 }, + 'components/agents/session-message-list.tsx': { 'protected-helper': 1 }, + 'components/agents/use-interaction-handlers.ts': { 'protected-helper': 3 }, + 'components/kilo-chat/message-list.tsx': { 'protected-helper': 1 }, + 'components/offline-banner.tsx': { 'protected-helper': 1 }, + 'components/pr-review/pr-review-pending-comment-row.tsx': { 'protected-helper': 1 }, + 'lib/a11y/announce.ts': { 'protected-native-adapter': 1 }, + 'lib/a11y/announcing-toast.ts': { 'protected-helper': 3 }, + 'lib/a11y/status-announcement.ts': { 'protected-helper': 1 }, + 'lib/agent-attachments/use-agent-attachment-upload.ts': { 'protected-helper': 1 }, + 'lib/local-access-privacy.ts': { 'announcement-dispatch': 1 }, + 'lib/pr-review/diff/use-pr-diff-list-scroll.ts': { 'protected-helper': 1 }, + 'lib/pr-review/merge/use-pr-merge-mutations.ts': { 'protected-helper': 2 }, + 'lib/pr-review/use-pr-review-mutations.ts': { 'protected-helper': 2 }, + 'lib/voice-input/use-voice-input-actions.ts': { 'protected-helper': 1 }, +}; + +function sources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === 'test' || entry.name === '__tests__' ? [] : sources(path); + } + return /\.[cm]?[jt]sx?$/.test(entry.name) && + !/\.test\.|test-helpers|test-utils/.test(entry.name) + ? [path] + : []; + }); +} + +function assertClassified(file: string, text: string) { + expect(announcements(text), `Unclassified announcement producer: ${file}`).toEqual( + inventory[file] ?? {} + ); +} + +describe('application announcement producer inventory', () => { + it('classifies every producer and permits no direct React Native announcement', () => { + const root = resolve('src'); + const actual = Object.fromEntries( + sources(root) + .map<[string, Counts]>(file => [ + relative(root, file), + announcements(readFileSync(file, 'utf8')), + ]) + .filter(([, counts]) => Object.keys(counts).length > 0) + ); + expect(actual).toEqual(inventory); + }); + + it.each([ + 'AccessibilityInfo.announceForAccessibility("secret");', + 'AccessibilityInfo.announceForAccessibilityWithOptions("secret", { queue: true });', + 'import { AccessibilityInfo as Info } from "react-native"; Info.announceForAccessibility("secret");', + 'import * as Native from "react-native"; Native.AccessibilityInfo.announceForAccessibility("secret");', + 'const { announceForAccessibility: say } = AccessibilityInfo; say("secret");', + 'const { "announceForAccessibility": say } = AccessibilityInfo; say("secret");', + 'const say = AccessibilityInfo["announceForAccessibility"]; say("secret");', + 'const callback = AccessibilityInfo.announceForAccessibility;', + 'import { announceForAccessibility as say } from "react-native"; say("secret");', + 'AccessibilityInfo[method]("secret");', + 'NativeModules.AccessibilityManager.announceForAccessibility("secret");', + 'AccessibilityInfo.sendAccessibilityEvent(1, "announcement");', + 'announceForA11y("secret");', + 'import { announceForA11y as say } from "@/lib/a11y/announce"; say("secret");', + 'announceLocalAccessPrivacy("secret", "gate");', + 'const { announceLocalAccessPrivacy: say } = Privacy; say("secret", "gate");', + 'native.announce("secret", 0, true);', + ])('rejects an unclassified producer: %s', fixture => { + expect(() => { + assertClassified('unclassified.tsx', fixture); + }).toThrow('Unclassified announcement producer'); + }); + + it.each([ + 'AccessibilityInfo.announceForAccessibility("secret");', + 'announceForA11y("extra");', + 'announceLocalAccessPrivacy("secret", "gate");', + ])('rejects an extra producer in an already classified file: %s', extra => { + const file = 'components/agents/session-message-list.tsx'; + const current = readFileSync(resolve('src', file), 'utf8'); + expect(() => { + assertClassified(file, `${current}\n${extra}`); + }).toThrow('Unclassified announcement producer'); + }); + + it.each([ + 'announceLocalAccessPrivacy("secret", "gate");', + 'import { announceLocalAccessPrivacy as say } from "@/lib/local-access-privacy"; say("secret", "gate");', + 'announceLocalAccessPrivacy.call(null, "secret", "gate");', + ])('rejects a protected helper changed to explicit native-kind delivery: %s', fixture => { + expect(() => { + assertClassified('lib/a11y/announce.ts', fixture); + }).toThrow('Unclassified announcement producer'); + }); + + it('ignores comments and unrelated accessibility methods', () => { + assertClassified( + 'unclassified.tsx', + '// AccessibilityInfo.announceForAccessibility("not executable");\nAccessibilityInfo.setAccessibilityFocus(42);' + ); + }); +}); diff --git a/apps/mobile/src/lib/a11y/announcing-toast.test.ts b/apps/mobile/src/lib/a11y/announcing-toast.test.ts index 83fceaf6c2..d966e218a1 100644 --- a/apps/mobile/src/lib/a11y/announcing-toast.test.ts +++ b/apps/mobile/src/lib/a11y/announcing-toast.test.ts @@ -1,8 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { announceForA11y } from './announce'; +import { createPrivacyNativeTestModule } from '../../../modules/local-access-privacy/tests/native-test-helpers'; import { announcingToast } from './announcing-toast'; +const adapter = vi.hoisted((): Parameters[0] => ({ + available: true, + nativeFailure: false, + secure: false, + captureFailure: false, + captureWait: undefined, + captureEvents: [], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + delivered: [], + queue: [], + listeners: new Map(), +})); + const sonnerMock = vi.hoisted(() => { // `sonner-native` exports a single `toast` callable that has `.success`, // `.error`, `.warning`, etc. attached as properties. Mirror that exact @@ -26,114 +39,143 @@ const sonnerMock = vi.hoisted(() => { return { callable, success, error, warning }; }); -const accessibilityMock = vi.hoisted(() => ({ - announceForAccessibility: vi.fn(), - setAccessibilityFocus: vi.fn(), +vi.mock('expo', () => ({ + requireNativeModule: () => createPrivacyNativeTestModule(adapter), +})); +vi.mock('expo-screen-capture', () => ({ + allowScreenCaptureAsync: vi.fn(), + preventScreenCaptureAsync: vi.fn(), })); - vi.mock('sonner-native', () => ({ toast: sonnerMock.callable, })); vi.mock('react-native', () => ({ - AccessibilityInfo: accessibilityMock, + Platform: { OS: 'ios' }, + AccessibilityInfo: { + // A direct React Native bypass must remain observable in denial tests. + announceForAccessibility: (message: string) => { + adapter.delivered.push(message); + }, + setAccessibilityFocus: vi.fn(), + }, findNodeHandle: vi.fn(), })); +function deliver() { + for (const task of adapter.queue.splice(0)) { + task(); + } +} + describe('announcingToast', () => { beforeEach(() => { - sonnerMock.success.mockClear(); - sonnerMock.error.mockClear(); - sonnerMock.warning.mockClear(); - accessibilityMock.announceForAccessibility.mockClear(); + adapter.available = true; + adapter.snapshot = { + generation: 0, + armed: false, + foreground: true, + covered: false, + failed: false, + }; + adapter.delivered = []; + adapter.queue = []; }); afterEach(() => { vi.clearAllMocks(); }); - it('success shows the toast AND announces the message', () => { - // oxlint-disable-next-line no-literal-copy/no-literal-copy - const result = announcingToast.success('Session renamed'); - - expect(sonnerMock.success).toHaveBeenCalledTimes(1); - expect(sonnerMock.success).toHaveBeenCalledWith('Session renamed', undefined); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledTimes(1); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith('Session renamed'); - expect(result).toBe('success-id'); + it.each([ + ['success', 'Session renamed', 'success-id'], + ['error', 'Network request failed', 'error-id'], + ['warning', 'Webhook sync partially failed', 'warning-id'], + ] as const)('%s shows the toast AND announces the message', (kind, message, id) => { + const result = announcingToast[kind](message); + + expect(sonnerMock[kind]).toHaveBeenCalledTimes(1); + expect(sonnerMock[kind]).toHaveBeenCalledWith(message, undefined); + expect(result).toBe(id); + expect(adapter.delivered).toEqual([]); + expect(adapter.queue).toHaveLength(1); + deliver(); + expect(adapter.delivered).toEqual([message]); }); - it('error shows the toast AND announces the message', () => { - // oxlint-disable-next-line no-literal-copy/no-literal-copy - const result = announcingToast.error('Network request failed'); - - expect(sonnerMock.error).toHaveBeenCalledTimes(1); - expect(sonnerMock.error).toHaveBeenCalledWith('Network request failed', undefined); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledTimes(1); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith( - 'Network request failed' - ); - expect(result).toBe('error-id'); - }); - - it('warning shows the toast AND announces the message', () => { - // oxlint-disable-next-line no-literal-copy/no-literal-copy - const result = announcingToast.warning('Webhook sync partially failed'); - - expect(sonnerMock.warning).toHaveBeenCalledTimes(1); - expect(sonnerMock.warning).toHaveBeenCalledWith('Webhook sync partially failed', undefined); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledTimes(1); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith( - 'Webhook sync partially failed' - ); - expect(result).toBe('warning-id'); - }); + it.each(['success', 'error', 'warning'] as const)( + '%s forwards sonner-native options without swallowing them', + kind => { + const options = { description: 'tap to retry' }; + const result = announcingToast[kind]('Save outcome', options); + deliver(); - it('forwards sonner-native options without swallowing them', () => { - const options = { description: 'tap to retry' }; - // oxlint-disable-next-line no-literal-copy/no-literal-copy - announcingToast.error('Save failed', options); + expect(sonnerMock[kind]).toHaveBeenCalledWith('Save outcome', options); + expect(result).toBe(`${kind}-id`); + expect(adapter.delivered).toEqual(['Save outcome']); + } + ); - expect(sonnerMock.error).toHaveBeenCalledWith('Save failed', options); - }); it('trims whitespace from the announced message so the screen reader hears the trimmed form', () => { - // oxlint-disable-next-line no-literal-copy/no-literal-copy announcingToast.error(' Too many requests '); + deliver(); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith('Too many requests'); + expect(sonnerMock.error).toHaveBeenCalledWith(' Too many requests ', undefined); + expect(adapter.delivered).toEqual(['Too many requests']); }); it('drops empty messages instead of announcing blank speech', () => { - announcingToast.success(''); + const result = announcingToast.success(''); expect(sonnerMock.success).toHaveBeenCalledWith('', undefined); - expect(accessibilityMock.announceForAccessibility).not.toHaveBeenCalled(); + expect(result).toBe('success-id'); + expect(adapter.queue).toEqual([]); + deliver(); + expect(adapter.delivered).toEqual([]); }); it('announces the same message the toast shows (sighted and screen-reader users hear the same outcome)', () => { - // Spot-check that announcement is derived from the actual toast title, - // not a separate label that could drift out of sync. const message = 'Existing remediations queued'; announcingToast.success(message); + deliver(); - const announced = accessibilityMock.announceForAccessibility.mock.calls[0]?.[0]; const toasted = sonnerMock.success.mock.calls[0]?.[0]; - expect(announced).toBe(toasted); - expect(announced).toBe(message); + expect(adapter.delivered).toEqual([toasted]); + expect(adapter.delivered).toEqual([message]); }); - it('reuses announceForA11y from the shared helper (no second announce utility)', () => { - // The adapter must delegate to the shared announce helper so screen-reader - // behavior stays in one place. The earlier "announces the same message" - // test already proves the message reaches AccessibilityInfo via - // announceForA11y (which is the only path in the adapter). This test - // additionally asserts the imported helper is the same function reference - // we import at the top of the test, so a future refactor that reaches - // for `AccessibilityInfo.announceForAccessibility` directly would be - // caught — the visible result would still pass, but the import - // wouldn't be reused. - expect(typeof announceForA11y).toBe('function'); - announcingToast.success('hello'); - announcingToast.error('oops'); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledTimes(2); - }); + it.each(['success', 'error', 'warning'] as const)( + '%s retains the toast outcome while covered without replaying speech after unlock', + kind => { + adapter.snapshot.armed = true; + adapter.snapshot.covered = true; + const options = { description: 'Existing outcome details' }; + const result = announcingToast[kind]('Protected outcome', options); + + expect(result).toBe(`${kind}-id`); + expect(sonnerMock[kind]).toHaveBeenCalledWith('Protected outcome', options); + expect(adapter.queue).toEqual([]); + deliver(); + expect(adapter.delivered).toEqual([]); + + adapter.snapshot.covered = false; + adapter.snapshot.generation += 1; + deliver(); + expect(adapter.delivered).toEqual([]); + announcingToast[kind]('Fresh outcome'); + deliver(); + expect(adapter.delivered).toEqual(['Fresh outcome']); + } + ); + + it.each(['success', 'error', 'warning'] as const)( + '%s retains the toast outcome when native speech is unavailable', + kind => { + adapter.available = false; + const result = announcingToast[kind]('Completed outcome'); + deliver(); + + expect(result).toBe(`${kind}-id`); + expect(sonnerMock[kind]).toHaveBeenCalledWith('Completed outcome', undefined); + expect(adapter.delivered).toEqual([]); + } + ); }); diff --git a/apps/mobile/src/lib/a11y/status-announcement.test.ts b/apps/mobile/src/lib/a11y/status-announcement.test.ts index 26526da08d..f3484d1592 100644 --- a/apps/mobile/src/lib/a11y/status-announcement.test.ts +++ b/apps/mobile/src/lib/a11y/status-announcement.test.ts @@ -1,7 +1,29 @@ import { describe, expect, it, vi } from 'vitest'; +import { createPrivacyNativeTestModule } from '../../../modules/local-access-privacy/tests/native-test-helpers'; import { nextAnnouncement } from './status-announcement'; +const adapter = vi.hoisted((): Parameters[0] => ({ + available: true, + nativeFailure: false, + secure: false, + captureFailure: false, + captureWait: undefined, + captureEvents: [], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + delivered: [], + queue: [], + listeners: new Map(), +})); + +vi.mock('expo', () => ({ + requireNativeModule: () => createPrivacyNativeTestModule(adapter), +})); +vi.mock('expo-screen-capture', () => ({ + allowScreenCaptureAsync: vi.fn(), + preventScreenCaptureAsync: vi.fn(), +})); + const accessibilityMock = vi.hoisted(() => ({ announceForAccessibility: vi.fn(), setAccessibilityFocus: vi.fn(), diff --git a/apps/mobile/src/lib/local-access-privacy-inventory.test.ts b/apps/mobile/src/lib/local-access-privacy-inventory.test.ts new file mode 100644 index 0000000000..58a5ccb127 --- /dev/null +++ b/apps/mobile/src/lib/local-access-privacy-inventory.test.ts @@ -0,0 +1,590 @@ +/* eslint-disable import/no-nodejs-modules -- this CI inventory reads source, not runtime device data */ +/* eslint-disable max-lines -- the complete presentation and dependency classifications form one audited contract */ +import { readdirSync, readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +type Counts = Record; + +function presentations(text: string): Counts { + const source = ts.createSourceFile( + 'presentation.tsx', + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const aliases = new Map(); + const counts: Counts = {}; + const add = (family: string) => { + counts[family] = (counts[family] ?? 0) + 1; + }; + function nameOf(expression: ts.Node): string { + if (ts.isIdentifier(expression)) { + return aliases.get(expression.text) ?? expression.text; + } + if (ts.isPropertyAccessExpression(expression)) { + const owner = nameOf(expression.expression); + return owner === 'Alert' ? `${owner}.${expression.name.text}` : expression.name.text; + } + if ( + ts.isElementAccessExpression(expression) && + ts.isStringLiteral(expression.argumentExpression) + ) { + const owner = nameOf(expression.expression); + return owner === 'Alert' + ? `${owner}.${expression.argumentExpression.text}` + : expression.argumentExpression.text; + } + return ''; + } + function visit(node: ts.Node) { + if (ts.isImportSpecifier(node)) { + aliases.set(node.name.text, node.propertyName?.text ?? node.name.text); + } + if (ts.isVariableDeclaration(node) && node.initializer) { + if (ts.isIdentifier(node.name)) { + aliases.set(node.name.text, nameOf(node.initializer)); + } + if (ts.isObjectBindingPattern(node.name)) { + for (const element of node.name.elements) { + if (ts.isIdentifier(element.name)) { + const property = element.propertyName?.getText(source) ?? element.name.text; + aliases.set( + element.name.text, + nameOf(node.initializer) === 'Alert' ? `Alert.${property}` : property + ); + } + } + } + } + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + nameOf(node.tagName) === 'Modal' + ) { + add('react-native-modal'); + } + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && + ['NativeModules', 'TurboModuleRegistry'].includes(nameOf(node.expression)) + ) { + add(`native-entry:${nameOf(node.expression)}`); + } + if (ts.isCallExpression(node)) { + const name = nameOf(node.expression); + if (name === 'Alert.alert' || name === 'Alert.prompt') { + add(name); + } + if (name === 'showActionSheetWithOptions') { + add('action-sheet'); + } + if (name === 'createElement' && node.arguments[0] && nameOf(node.arguments[0]) === 'Modal') { + add('react-native-modal'); + } + if ( + [ + 'requireNativeModule', + 'requireOptionalNativeModule', + 'requireNativeComponent', + 'requireNativeView', + ].includes(name) + ) { + const argument = node.arguments[0]; + add(`native-entry:${argument && ts.isStringLiteral(argument) ? argument.text : 'dynamic'}`); + } + } + if ( + ts.isPropertyAssignment(node) && + node.name.getText(source).replaceAll(/['"]/g, '') === 'presentation' + ) { + let value = node.initializer; + while ( + ts.isAsExpression(value) || + ts.isParenthesizedExpression(value) || + ts.isSatisfiesExpression(value) + ) { + value = value.expression; + } + if (ts.isStringLiteral(value)) { + add(`native-stack:${value.text}`); + } else { + let parent: ts.Node = node.parent; + while (!ts.isSourceFile(parent) && !ts.isJsxAttribute(parent)) { + parent = parent.parent; + } + if ( + ts.isJsxAttribute(parent) && + ['options', 'screenOptions'].includes(parent.name.getText(source)) + ) { + add('native-stack:dynamic'); + } + } + } + ts.forEachChild(node, visit); + } + visit(source); + return counts; +} + +// r4's full presentation inventory, plus multiline Modal sites. No alert payload is a safe exception. +// Android: native-stack = Activity content; action-sheet = root/Modal. iOS: all belong to scene windows. +const inventory: Record = { + 'app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx': { 'native-stack:formSheet': 1 }, + 'app/(app)/(tabs)/(2_agents)/index.tsx': { 'Alert.alert': 2 }, + 'app/(app)/(tabs)/(3_profile)/organization/_layout.tsx': { 'native-stack:formSheet': 1 }, + 'app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx': { + 'native-stack:formSheet': 2, + }, + 'app/(app)/_layout.tsx': { 'native-stack:formSheet': 7, 'native-stack:modal': 3 }, + 'app/(app)/agent-chat/use-new-session-discard-guard.ts': { 'Alert.alert': 1 }, + 'app/(app)/kiloclaw/[instance-id]/changelog.tsx': { 'Alert.alert': 1 }, + 'app/(app)/kiloclaw/[instance-id]/dashboard.tsx': { 'Alert.alert': 1 }, + 'app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx': { 'Alert.alert': 2 }, + 'app/(app)/kiloclaw/[instance-id]/settings/google.tsx': { 'Alert.alert': 2 }, + 'app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx': { 'Alert.alert': 1 }, + 'app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx': { 'native-stack:formSheet': 1 }, + 'app/(auth)/_layout.tsx': { 'native-stack:formSheet': 1 }, + 'components/agents/attachment-picker.ts': { 'Alert.alert': 1, 'action-sheet': 1 }, + 'components/agents/attachment-preview-strip.tsx': { 'action-sheet': 1 }, + 'components/agents/chat-markdown-text.tsx': { 'action-sheet': 2 }, + 'components/agents/file-part-renderer.tsx': { 'action-sheet': 1 }, + 'components/agents/markdown-link-confirm.ts': { 'Alert.alert': 1 }, + 'components/agents/markdown-table.tsx': { 'react-native-modal': 1 }, + 'components/agents/message-details-sheet.tsx': { 'Alert.alert': 1 }, + 'components/agents/platform-filter-modal.tsx': { 'react-native-modal': 1 }, + 'components/agents/question-card.tsx': { 'Alert.alert': 1 }, + 'components/agents/remote-session-exit-alert.ts': { 'Alert.alert': 1 }, + 'components/agents/session-page-sheet.tsx': { 'react-native-modal': 2 }, + 'components/agents/session-row-actions.ts': { + 'Alert.alert': 1, + 'Alert.prompt': 1, + 'action-sheet': 1, + }, + 'components/agents/use-message-copy.ts': { 'action-sheet': 1 }, + 'components/code-reviewer/review-detail-screen.tsx': { 'Alert.alert': 2 }, + 'components/consent/consent-card.tsx': { 'Alert.alert': 1 }, + 'components/device-sessions-screen.tsx': { 'Alert.alert': 2 }, + 'components/image-viewer-modal.tsx': { 'react-native-modal': 1 }, + 'components/kilo-chat/conversation-row.tsx': { 'Alert.alert': 1, 'action-sheet': 1 }, + 'components/kilo-chat/hooks/use-conversation-message-actions.ts': { + 'Alert.alert': 1, + 'action-sheet': 1, + }, + 'components/kilo-chat/hooks/use-conversation-options-sheet.ts': { + 'Alert.alert': 1, + 'action-sheet': 1, + }, + 'components/kilo-chat/message-attachment-picker.ts': { 'Alert.alert': 1 }, + 'components/kilo-chat/message-input-attachment-queue.tsx': { 'action-sheet': 1 }, + 'components/kiloclaw/instance-controls.tsx': { 'Alert.alert': 4 }, + 'components/kiloclaw/onboarding/identity-step.tsx': { 'Alert.alert': 1 }, + 'components/kiloclaw/onboarding/notifications-step.tsx': { 'Alert.alert': 1 }, + 'components/kiloclaw/settings-card.tsx': { 'Alert.alert': 1 }, + 'components/notifications-screen.tsx': { 'Alert.alert': 2 }, + 'components/organization/invited-member-row.tsx': { 'Alert.alert': 1, 'action-sheet': 1 }, + 'components/organization/member-row.tsx': { 'Alert.alert': 1, 'action-sheet': 2 }, + 'components/pr-review/discussion/comment-row.tsx': { 'Alert.alert': 1, 'action-sheet': 1 }, + 'components/pr-review/discussion/reaction-picker-sheet.tsx': { 'react-native-modal': 1 }, + 'components/pr-review/discussion/reply-input.tsx': { 'Alert.alert': 2 }, + 'components/pr-review/merge/pr-merge-sheet.tsx': { 'Alert.alert': 2 }, + 'components/pr-review/pr-review-comment-composer-screen.tsx': { 'Alert.alert': 1 }, + 'components/pr-review/pr-review-comment-composer.tsx': { 'Alert.alert': 1 }, + 'components/pr-review/pr-review-entry-screen.tsx': { 'Alert.alert': 1 }, + 'components/pr-review/pr-review-submit.tsx': { 'Alert.alert': 1 }, + 'components/profile-credits-card.tsx': { 'action-sheet': 1 }, + 'components/profile-screen.tsx': { 'Alert.alert': 2 }, + 'components/rename-modal.tsx': { 'react-native-modal': 1 }, + 'components/security-agent/automation-settings-screen.tsx': { 'Alert.alert': 1 }, + 'components/security-agent/dashboard-screen.tsx': { 'action-sheet': 1 }, + 'components/security-agent/finding-analysis-panel.tsx': { 'Alert.alert': 1 }, + 'components/security-agent/finding-remediation-panel.tsx': { 'Alert.alert': 1 }, + 'components/share/share-gate-sheet.tsx': { 'Alert.alert': 2 }, + 'lib/feedback.ts': { 'Alert.alert': 1 }, + 'lib/hooks/use-settings-back-guard.ts': { 'Alert.alert': 1 }, + 'lib/hooks/use-tracking-permission-prompt.ts': { 'Alert.alert': 1 }, + 'lib/local-access-privacy.ts': { 'native-entry:LocalAccessPrivacy': 1 }, + 'lib/voice-input/use-voice-input-actions.ts': { 'Alert.alert': 2 }, +}; + +// Classify every dependency, not just names matching "expo" or "native". A new window library must fail closed. +const libraries = { + applicationWindows: [ + 'react-native', + 'react-native-screens', + 'expo-router', + '@expo/react-native-action-sheet', + ], + rootContent: [ + '@rn-primitives/portal', + '@rn-primitives/slot', + 'sonner-native', + '@shopify/flash-list', + 'react-native-marked', + ], + systemPresentation: [ + '@react-native-google-signin/google-signin', + 'expo-apple-authentication', + 'expo-clipboard', + 'expo-document-picker', + 'expo-iap', + 'expo-image-picker', + 'expo-local-authentication', + 'expo-location', + 'expo-notifications', + 'expo-share-intent', + 'expo-sharing', + 'expo-speech-recognition', + 'expo-store-review', + 'expo-tracking-transparency', + 'expo-web-browser', + ], + noAdditionalProductWindows: [ + '@expo-google-fonts/jetbrains-mono', + '@expo/app-integrity', + '@formatjs/intl-durationformat', + '@formatjs/intl-listformat', + '@formatjs/intl-locale', + '@formatjs/intl-numberformat', + '@formatjs/intl-pluralrules', + '@formatjs/intl-relativetimeformat', + '@formatjs/intl-segmenter', + '@kilocode/app-shared', + '@kilocode/cloud-agent-sdk', + '@kilocode/event-service', + '@kilocode/kilo-chat', + '@kilocode/kilo-chat-hooks', + '@kilocode/notifications', + '@kilocode/trpc', + '@react-native-community/netinfo', + '@sentry/react-native', + '@tailwindcss/postcss', + '@tanstack/query-async-storage-persister', + '@tanstack/react-query', + '@tanstack/react-query-persist-client', + '@trpc/client', + '@trpc/tanstack-react-query', + 'class-variance-authority', + 'clsx', + 'drizzle-orm', + 'expo', + 'expo-application', + 'expo-blur', + 'expo-build-properties', + 'expo-constants', + 'expo-crypto', + 'expo-dev-client', + 'expo-device', + 'expo-file-system', + 'expo-font', + 'expo-haptics', + 'expo-image', + 'expo-image-manipulator', + 'expo-keep-awake', + 'expo-linear-gradient', + 'expo-linking', + 'expo-localization', + 'expo-screen-capture', + 'expo-screen-corner-radius', + 'expo-secure-store', + 'expo-splash-screen', + 'expo-sqlite', + 'expo-status-bar', + 'i18next', + 'jotai', + 'lowlight', + 'lucide-react-native', + 'nativewind', + 'posthog-react-native', + 'react', + 'react-i18next', + 'react-native-appsflyer', + 'react-native-css', + 'react-native-gesture-handler', + 'react-native-reanimated', + 'react-native-safe-area-context', + 'react-native-svg', + 'react-native-worklets', + 'tailwind-merge', + 'tailwindcss', + 'ulid', + 'zod', + ], +}; +const knownLibraries = Object.values(libraries).flat(); + +function assertLibraries(names: string[]) { + expect(names.toSorted(), 'Unclassified dependency or retired window contract').toEqual( + knownLibraries.toSorted() + ); +} + +function sources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === 'test' ? [] : sources(path); + } + return /\.tsx?$/.test(entry.name) && !/\.test\.|test-helpers|test-utils/.test(entry.name) + ? [path] + : []; + }); +} + +function assertClassified(file: string, text: string) { + expect(presentations(text), `Unclassified native presentation: ${file}`).toEqual( + inventory[file] ?? {} + ); +} + +describe('application presentation inventory', () => { + it('classifies every current presentation, including multiline Modals and every alert', () => { + const root = resolve('src'); + const actual = Object.fromEntries( + sources(root) + .map<[string, Counts]>(file => [ + relative(root, file), + presentations(readFileSync(file, 'utf8')), + ]) + .filter(([, counts]) => Object.keys(counts).length > 0) + ); + expect(actual).toEqual(inventory); + }); + it.each([ + 'import { Modal as Sheet } from "react-native"; const view = ;', + 'import * as Native from "react-native"; const view = ;', + 'import { Modal } from "react-native"; React.createElement(Modal);', + 'import { Alert as Dialog } from "react-native"; Dialog.alert("secret");', + 'const { prompt: show } = Alert; show("secret");', + 'const show = Alert["alert"]; show("secret");', + 'const view = ;', + 'const view = ;', + 'const view = ;', + 'ActionSheetIOS.showActionSheetWithOptions({});', + 'const { showActionSheetWithOptions: show } = useActionSheet(); show({});', + 'requireNativeView("UnregisteredWindow");', + 'NativeModules.UnregisteredDialog.show();', + 'TurboModuleRegistry.getEnforcing("UnregisteredWindow");', + ])('rejects a new unclassified presentation: %s', fixture => { + expect(() => { + assertClassified('unclassified.tsx', fixture); + }).toThrow(); + }); + it('rejects an additional family inside an already classified file', () => { + expect(() => { + assertClassified('components/rename-modal.tsx', '<>'); + }).toThrow(); + }); + it('requires a classification for each installed dependency', () => { + const manifest = JSON.parse(readFileSync('package.json', 'utf8')) as { + dependencies: Record; + }; + assertLibraries(Object.keys(manifest.dependencies)); + }); + it('rejects a new library without relying on a native naming convention', () => { + expect(() => { + assertLibraries([...knownLibraries, 'another-window-kit']); + }).toThrow(); + }); +}); + +const moduleRoot = resolve('modules/local-access-privacy'); +const android = 'android/src/main/java/expo/modules/localaccessprivacy/'; +const source = (path: string) => readFileSync(resolve(moduleRoot, path), 'utf8'); + +function assertImmediateOpacity(text: string) { + expect(text).not.toMatch( + /asyncAfter|postDelayed|UIView\.animate|ValueAnimator|enableAppSwitcherProtection|isSecureTextEntry/ + ); +} + +type NativeRegistration = { + platforms: string[]; + apple: { modules: string[]; appDelegateSubscribers: string[] }; + android: { modules: string[] }; +}; + +function assertNativeRegistration(config: NativeRegistration) { + const swift = /public final class (\w+): Module\b/.exec( + source('ios/LocalAccessPrivacyModule.swift') + )?.[1]; + const subscriber = /public final class (\w+): ExpoAppDelegateSubscriber\b/.exec( + source('ios/LocalAccessPrivacyAppDelegateSubscriber.swift') + )?.[1]; + const kotlin = source(`${android}LocalAccessPrivacyModule.kt`); + const packageName = /^package (\S+)/m.exec(kotlin)?.[1]; + const className = /^class (\w+) : Module\(\)/m.exec(kotlin)?.[1]; + expect(config.platforms.toSorted()).toEqual(['android', 'apple']); + expect(config.apple.modules).toEqual([swift]); + expect(config.apple.appDelegateSubscribers).toEqual([subscriber]); + expect(config.android.modules).toEqual([`${packageName}.${className}`]); +} + +function assertNativeLibraries(gradle: string, podspec: string) { + const androidImports = [ + ...gradle.matchAll(/^\s*(?:implementation|api|compileOnly|runtimeOnly)\b(.+)$/gm), + ].map(match => match[1]?.trim()); + const appleImports = [...podspec.matchAll(/\bs\.dependency\s+(.+)/g)].map(match => + match[1]?.trim() + ); + expect(androidImports).toEqual(["'androidx.fragment:fragment-ktx:1.8.9'"]); + expect(appleImports).toEqual(["'ExpoModulesCore'"]); +} + +function assertNativeModules(names: string[]) { + expect( + names.toSorted(), + 'A new local native module needs a window-family classification' + ).toEqual(['local-access-privacy']); +} + +describe('native window source contracts, not device snapshot proof', () => { + it('classifies local native modules and both native dependency declarations', () => { + assertNativeModules( + readdirSync('modules', { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + ); + assertNativeLibraries(source('android/build.gradle'), source('ios/LocalAccessPrivacy.podspec')); + }); + it('rejects an unclassified local native module', () => { + expect(() => { + assertNativeModules(['local-access-privacy', 'new-window-module']); + }).toThrow(); + }); + it.each(['android', 'apple'])('rejects an unclassified native dependency on %s', platform => { + const gradle = source('android/build.gradle'); + const podspec = source('ios/LocalAccessPrivacy.podspec'); + const changedGradle = + platform === 'android' + ? gradle.replace( + "implementation 'androidx.fragment:fragment-ktx:1.8.9'", + "implementation 'androidx.fragment:fragment-ktx:1.8.9'\n implementation('unclassified:window:1')" + ) + : gradle; + const changedPodspec = + platform === 'apple' + ? podspec.replace( + "s.dependency 'ExpoModulesCore'", + "s.dependency 'ExpoModulesCore'\n s.dependency 'UnclassifiedWindow'" + ) + : podspec; + expect(() => { + assertNativeLibraries(changedGradle, changedPodspec); + }).toThrow(); + }); + it.each(['platforms', 'apple-module', 'subscriber', 'android-module'])( + 'rejects missing native registration: %s', + field => { + const config = JSON.parse(source('expo-module.config.json')) as NativeRegistration; + if (field === 'platforms') { + config.platforms = []; + } else if (field === 'apple-module') { + config.apple.modules = []; + } else if (field === 'subscriber') { + config.apple.appDelegateSubscribers = []; + } else { + config.android.modules = []; + } + expect(() => { + assertNativeRegistration(config); + }).toThrow(); + } + ); + it('connects the Expo registration to real modules, subscribers, and the TypeScript native entry', () => { + const config = JSON.parse(source('expo-module.config.json')) as NativeRegistration; + assertNativeRegistration(config); + const entry = /requireNativeModule\('([^']+)'\)/.exec( + readFileSync('src/lib/local-access-privacy.ts', 'utf8') + )?.[1]; + expect(entry).toBeDefined(); + expect(source('ios/LocalAccessPrivacyModule.swift')).toContain(`Name("${entry}")`); + expect(source(`${android}LocalAccessPrivacyModule.kt`)).toContain(`Name("${entry}")`); + expect(source(`${android}LocalAccessPrivacyPackage.kt`)).toMatch( + /import expo\.modules\.core\.interfaces\.Package/ + ); + expect(source('ios/LocalAccessPrivacy.podspec')).toContain("s.dependency 'ExpoModulesCore'"); + expect(source('android/build.gradle')).toContain("id 'expo-module-gradle-plugin'"); + }); + it('covers UIKit scenes and alert+1 application windows without changing system windows', () => { + const coordinator = source('ios/LocalAccessPrivacy.swift'); + expect(coordinator).toMatch(/connectedScenes[\s\S]*scene\.windows/); + expect(coordinator).toContain('UIScene.willDeactivateNotification'); + expect(coordinator).toContain('UIWindow.didBecomeVisibleNotification'); + expect(coordinator).toContain('UIApplication.shared.windows'); + expect(coordinator).toContain('updateLegacyWindows(legacyWindows)'); + expect(coordinator).toContain('level: topLevel + 1'); + expect(coordinator).toContain('window.accessibilityElementsHidden = true'); + expect(coordinator).toContain('window.accessibilityElementsHidden = previousAccessibility'); + expect(source('ios/LocalAccessPrivacyAppDelegateSubscriber.swift')).toMatch( + /applicationWillResignActive[\s\S]*applicationActive\(false\)/ + ); + expect(source('ios/PrivacySceneWindow.swift')).toContain( + 'override var canBecomeKey: Bool { acceptsKey }' + ); + const alert = readFileSync( + 'node_modules/react-native/React/CoreModules/RCTAlertController.mm', + 'utf8' + ); + expect(alert).toContain('UIWindowLevelAlert + 1'); + }); + it('registers immediate Android lifecycle, pre-show alerts, and synchronous Modal creation', () => { + const coordinator = source(`${android}LocalAccessPrivacy.kt`); + expect(coordinator).toContain('application.registerActivityLifecycleCallbacks(this)'); + expect(coordinator).toContain('registerFragmentLifecycleCallbacks(fragments, true)'); + expect(coordinator).toMatch(/onFragmentActivityCreated[\s\S]*registerDialog\(f\)/); + expect(coordinator).toMatch(/dialog\.create\(\)[\s\S]*dialog\.window\?\.let \{ register/); + expect(coordinator).toContain('context.addExtraWindowEventListener(this)'); + expect(coordinator).toContain('view is ReactModalHostView'); + expect(coordinator).toMatch(/onExtraWindowCreate[\s\S]*register\(window/); + expect(coordinator).toContain('onDetach = { detachWindow(window) }'); + expect(coordinator).toContain('if (!window.decorView.isAttachedToWindow) unregister(window)'); + expect(coordinator).toContain('androidx.biometric.'); + const cover = source(`${android}ApplicationWindowCover.kt`); + expect(cover).toContain('OnPreDrawListener'); + expect(cover).toContain('return@OnPreDrawListener false'); + expect(cover).toContain('IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS'); + expect(cover).toContain('view.importantForAccessibility = previous'); + expect(cover).toContain('hideSoftInputFromWindow'); + expect(cover).toContain('FLAG_SECURE'); + }); + it('keeps assertions synchronous and checks announcements after native queue waits', () => { + for (const path of [ + 'ios/LocalAccessPrivacyModule.swift', + `${android}LocalAccessPrivacyModule.kt`, + ]) { + const text = source(path); + expect(text).toMatch(/\n\s+Function\("isForegroundAllowed"\)/); + expect(text).toMatch(/\n\s+Function\("publishVisibility"\)/); + expect(text).toMatch( + /AsyncFunction\("announce"\)[\s\S]*runOnQueue\((?:\.main|Queues.MAIN)\)/ + ); + } + expect(source('ios/LocalAccessPrivacyModule.swift')).toMatch( + /guard LocalAccessPrivacy.shared.admitsAnnouncement[\s\S]*UIAccessibility.post/ + ); + expect(source(`${android}LocalAccessPrivacy.kt`)).toMatch( + /if \(!state.admitsAnnouncement[\s\S]*manager.sendAccessibilityEvent/ + ); + }); + it('uses immediate native opacity without delayed blur or secure-layer reparenting', () => { + for (const path of [ + 'ios/LocalAccessPrivacy.swift', + 'ios/PrivacySceneWindow.swift', + 'ios/LocalAccessPrivacyAppDelegateSubscriber.swift', + `${android}LocalAccessPrivacy.kt`, + `${android}ApplicationWindowCover.kt`, + ]) { + assertImmediateOpacity(source(path)); + } + }); + it.each([ + 'UIView.animate(withDuration: 0.3)', + 'handler.postDelayed(cover, 300)', + 'isSecureTextEntry = true', + ])('rejects a delayed or incompatible opacity implementation: %s', fixture => { + expect(() => { + assertImmediateOpacity(fixture); + }).toThrow(); + }); +}); diff --git a/apps/mobile/src/lib/local-access-privacy.test.ts b/apps/mobile/src/lib/local-access-privacy.test.ts new file mode 100644 index 0000000000..5e84ab0a47 --- /dev/null +++ b/apps/mobile/src/lib/local-access-privacy.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createCaptureNativeTestModule, + createPrivacyNativeTestModule, +} from '../../modules/local-access-privacy/tests/native-test-helpers'; +import * as LocalAccess from '@/lib/local-access'; + +const adapter = vi.hoisted(() => ({ + available: true, + nativeFailure: false, + platform: 'android', + secure: false, + captureFailure: false, + captureWait: undefined as Promise | undefined, + captureEvents: [] as string[], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + access: { + userId: 'user-a' as string | null, + authEpoch: 0, + unlockGeneration: 0, + unlocked: true, + contextReady: true, + foregroundReady: true, + }, + accessListeners: new Set<() => void>(), + delivered: [] as string[], + queue: [] as (() => void)[], + listeners: new Map void>(), +})); + +vi.mock('react-native', () => ({ + Platform: { + get OS() { + return adapter.platform; + }, + }, +})); +vi.mock('@/lib/local-access', async importOriginal => ({ + ...(await importOriginal()), + getLocalAccessSnapshot: () => adapter.access, + subscribeLocalAccess: (listener: () => void) => { + adapter.accessListeners.add(listener); + return () => adapter.accessListeners.delete(listener); + }, +})); +const captureNativePath = await vi.hoisted(async () => { + // eslint-disable-next-line import/no-nodejs-modules -- resolve Expo's nested native dependency for the test + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + return createRequire(require.resolve('expo-screen-capture')).resolve('expo-modules-core'); +}); +vi.mock(captureNativePath, () => createCaptureNativeTestModule(adapter)); +vi.mock('expo', () => ({ + requireNativeModule: () => createPrivacyNativeTestModule(adapter), + createPermissionHook: vi.fn(), + PermissionStatus: { GRANTED: 'granted' }, +})); + +function deliver() { + for (const task of adapter.queue.splice(0)) { + task(); + } +} + +function publishAccess() { + for (const listener of adapter.accessListeners) { + listener(); + } +} + +function inactive() { + adapter.snapshot.foreground = false; + adapter.snapshot.covered = adapter.snapshot.armed; + adapter.snapshot.generation += 1; +} + +beforeEach(() => { + vi.resetModules(); + // Execute installed Expo ownership logic, including activeTags retained after native rejection. + vi.doMock('expo-screen-capture', async () => { + const capture = await vi.importActual('expo-screen-capture/src/ScreenCapture'); + return capture; + }); + adapter.available = true; + adapter.nativeFailure = false; + adapter.platform = 'android'; + adapter.secure = false; + adapter.captureFailure = false; + adapter.captureWait = undefined; + adapter.captureEvents = []; + adapter.snapshot = { + generation: 0, + armed: false, + foreground: true, + covered: false, + failed: false, + }; + adapter.access = { + userId: 'user-a', + authEpoch: 0, + unlockGeneration: 0, + unlocked: true, + contextReady: true, + foregroundReady: true, + }; + adapter.accessListeners.clear(); + adapter.delivered = []; + adapter.queue = []; + adapter.listeners.clear(); +}); + +describe('native privacy bridge', () => { + it('does not activate protection on import', async () => { + const privacy = await import('./local-access-privacy'); + expect(privacy.getLocalAccessPrivacySnapshot().armed).toBe(false); + expect(adapter.secure).toBe(false); + }); + + it('covers immediately and waits for maintained capture protection before publication', async () => { + const privacy = await import('./local-access-privacy'); + const capture = Promise.withResolvers(); + adapter.captureWait = capture.promise; + const arming = privacy.armLocalAccessPrivacy(); + expect(adapter.snapshot.covered).toBe(true); + expect(privacy.publishLocalAccessVisibility(adapter.snapshot.generation)).toBe(false); + capture.resolve(undefined); + const armed = await arming; + expect(adapter.secure).toBe(true); + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(true); + expect(adapter.snapshot.covered).toBe(false); + }); + + it('keeps iOS arming independent of incompatible screen-capture reparenting', async () => { + const privacy = await import('./local-access-privacy'); + adapter.platform = 'ios'; + adapter.captureFailure = true; + const armed = await privacy.armLocalAccessPrivacy(); + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(true); + expect(adapter.secure).toBe(false); + }); + + it.each(['userId', 'unlocked', 'contextReady', 'foregroundReady'] as const)( + 'keeps content covered without current %s', + async field => { + const privacy = await import('./local-access-privacy'); + const armed = await privacy.armLocalAccessPrivacy(); + if (field === 'userId') { + adapter.access.userId = null; + } else { + adapter.access[field] = false; + } + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(false); + expect(adapter.snapshot.covered).toBe(true); + } + ); + + it.each([ + { userId: 'user-b' }, + { authEpoch: 1 }, + { unlockGeneration: 1 }, + { unlocked: false }, + { contextReady: false }, + { foregroundReady: false }, + ])('revokes native visibility synchronously for a shared access change: %j', async change => { + const privacy = await import('./local-access-privacy'); + const armed = await privacy.armLocalAccessPrivacy(); + privacy.publishLocalAccessVisibility(armed.generation); + adapter.access = { ...adapter.access, ...change }; + publishAccess(); + expect(adapter.snapshot.covered).toBe(true); + adapter.access = { + ...adapter.access, + unlocked: true, + contextReady: true, + foregroundReady: true, + }; + publishAccess(); + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(false); + expect(privacy.publishLocalAccessVisibility(adapter.snapshot.generation)).toBe(true); + }); + + it('rejects stale publication when native inactivity precedes the JavaScript event', async () => { + const privacy = await import('./local-access-privacy'); + const armed = await privacy.armLocalAccessPrivacy(); + inactive(); + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(false); + expect(() => { + privacy.assertNativeForeground(); + }).toThrow(LocalAccess.LocalAccessDeniedError); + adapter.snapshot.foreground = true; + adapter.snapshot.generation += 1; + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(false); + expect(adapter.snapshot.covered).toBe(true); + expect(privacy.publishLocalAccessVisibility(adapter.snapshot.generation)).toBe(true); + }); + + it.each([false, true])( + 'requires native capture repair and releases only owned keys (other consumer: %s)', + async otherConsumer => { + const privacy = await import('./local-access-privacy'); + const capture = await import('expo-screen-capture'); + if (otherConsumer) { + await capture.preventScreenCaptureAsync('other-consumer'); + } + adapter.captureFailure = true; + for (let attempt = 0; attempt < 3; attempt += 1) { + // eslint-disable-next-line no-await-in-loop -- each explicit Retry must follow the previous rejection + await expect(privacy.armLocalAccessPrivacy()).rejects.toThrow('Capture unavailable'); + expect(privacy.publishLocalAccessVisibility(adapter.snapshot.generation)).toBe(false); + expect(adapter.snapshot.covered).toBe(true); + expect(adapter.secure).toBe(otherConsumer); + } + adapter.captureFailure = false; + const retried = await privacy.armLocalAccessPrivacy(); + expect(adapter.secure).toBe(true); + expect(privacy.publishLocalAccessVisibility(retried.generation)).toBe(true); + await privacy.disarmLocalAccessPrivacy(); + expect(adapter.secure).toBe(otherConsumer); + if (otherConsumer) { + await capture.allowScreenCaptureAsync('other-consumer'); + expect(adapter.secure).toBe(false); + } + } + ); + + it.each(['available', 'nativeFailure'] as const)( + 'denies native effects after %s failure', + async kind => { + const privacy = await import('./local-access-privacy'); + adapter.available = kind !== 'available'; + adapter.nativeFailure = kind === 'nativeFailure'; + expect(() => { + privacy.assertNativeForeground(); + }).toThrow(LocalAccess.LocalAccessDeniedError); + } + ); + + it('rejects activation and speech when the native module is missing', async () => { + const privacy = await import('./local-access-privacy'); + adapter.available = false; + await expect(privacy.armLocalAccessPrivacy()).rejects.toThrow('Missing native privacy'); + expect(await privacy.announceLocalAccessPrivacy('protected')).toBe(false); + expect(adapter.delivered).toEqual([]); + }); + + it('serializes disarm and rearm without a late capture release', async () => { + const privacy = await import('./local-access-privacy'); + const capture = Promise.withResolvers(); + adapter.captureWait = capture.promise; + const first = privacy.armLocalAccessPrivacy(); + const stale = expect(first).rejects.toThrow('stale'); + const release = privacy.disarmLocalAccessPrivacy(); + const replacement = privacy.armLocalAccessPrivacy(); + capture.resolve(undefined); + await stale; + await release; + const armed = await replacement; + expect(adapter.captureEvents).toEqual(['prevent', 'allow', 'prevent']); + expect(adapter.secure).toBe(true); + expect(privacy.publishLocalAccessVisibility(armed.generation)).toBe(true); + }); + + it('rejects native-queued speech after inactivity and never replays it after unlock', async () => { + const privacy = await import('./local-access-privacy'); + const armed = await privacy.armLocalAccessPrivacy(); + privacy.publishLocalAccessVisibility(armed.generation); + const speech = privacy.announceLocalAccessPrivacy('secret transcript'); + inactive(); + adapter.snapshot.foreground = true; + adapter.snapshot.generation += 1; + privacy.publishLocalAccessVisibility(adapter.snapshot.generation); + deliver(); + expect(await speech).toBe(false); + expect(adapter.delivered).toEqual([]); + }); + + it('does not enqueue protected speech while covered and permits explicit non-sensitive gate speech', async () => { + const privacy = await import('./local-access-privacy'); + await privacy.armLocalAccessPrivacy(); + expect(await privacy.announceLocalAccessPrivacy('secret')).toBe(false); + const speech = privacy.announceLocalAccessPrivacy('Unlock required', 'gate'); + deliver(); + expect(await speech).toBe(true); + expect(adapter.delivered).toEqual(['Unlock required']); + }); + + it('preserves fresh disarmed speech without reviving queued authenticated speech', async () => { + const privacy = await import('./local-access-privacy'); + const old = privacy.announceLocalAccessPrivacy('old owner'); + await privacy.armLocalAccessPrivacy(); + await privacy.disarmLocalAccessPrivacy(); + deliver(); + expect(await old).toBe(false); + const fresh = privacy.announceLocalAccessPrivacy('public status'); + adapter.access = { ...adapter.access, userId: 'user-b' }; + publishAccess(); + deliver(); + expect(await fresh).toBe(true); + expect(adapter.delivered).toEqual(['public status']); + expect(adapter.secure).toBe(false); + }); + + it('does not execute a queued gate action after its generation changes', async () => { + const privacy = await import('./local-access-privacy'); + const armed = await privacy.armLocalAccessPrivacy(); + const actions: string[] = []; + const unsubscribe = privacy.subscribeLocalAccessPrivacyGateActions(id => { + actions.push(id); + }); + const listener = adapter.listeners.get('onGateAction') as (event: { + generation: number; + id: string; + }) => void; + inactive(); + adapter.snapshot.foreground = true; + listener({ generation: armed.generation, id: 'retry' }); + expect(actions).toEqual([]); + listener({ generation: adapter.snapshot.generation, id: 'retry' }); + expect(actions).toEqual(['retry']); + unsubscribe(); + expect(adapter.listeners.size).toBe(0); + }); +}); diff --git a/apps/mobile/src/lib/local-access-privacy.ts b/apps/mobile/src/lib/local-access-privacy.ts new file mode 100644 index 0000000000..da54744fc2 --- /dev/null +++ b/apps/mobile/src/lib/local-access-privacy.ts @@ -0,0 +1,210 @@ +import { type NativeModule, requireNativeModule } from 'expo'; +import { allowScreenCaptureAsync, preventScreenCaptureAsync } from 'expo-screen-capture'; +import { Platform } from 'react-native'; + +import { + getLocalAccessSnapshot, + LocalAccessDeniedError, + type LocalAccessSnapshot, + subscribeLocalAccess, +} from '@/lib/local-access'; + +export type LocalAccessPrivacySnapshot = Readonly<{ + generation: number; + armed: boolean; + foreground: boolean; + covered: boolean; + failed: boolean; +}>; + +/** Only non-sensitive, translated gate copy belongs here. The shell owns every action decision. */ +export type LocalAccessPrivacyGate = Readonly<{ + title: string; + message: string; + actions: readonly Readonly<{ id: string; label: string; enabled: boolean }>[]; +}>; + +type GateAction = Readonly<{ generation: number; id: string }>; +// Expo 57's NativeModule type alias describes the constructor, not its event-emitting instance. +type PrivacyModule = InstanceType< + typeof NativeModule<{ + onVisibilityChange: (snapshot: LocalAccessPrivacySnapshot) => void; + onGateAction: (action: GateAction) => void; + }> +> & { + arm: () => void; + disarm: () => void; + cover: () => void; + getSnapshot: () => LocalAccessPrivacySnapshot; + publishVisibility: (generation: number) => boolean; + isForegroundAllowed: () => boolean; + setGate: (generation: number, gate: LocalAccessPrivacyGate | null) => boolean; + announce: (message: string, generation: number, gate: boolean) => Promise; +}; + +// Loading this entry never arms protection. The authenticated shell must opt in before mounting. +function nativePrivacy(): PrivacyModule { + return requireNativeModule('LocalAccessPrivacy'); +} + +const CAPTURE_KEY = 'local-access-privacy'; +let captureReady = false; +let armAttempt = 0; +let captureQueue: Promise | undefined = undefined; +let stopAccessObservation: (() => void) | undefined = undefined; + +function accessReady(access: LocalAccessSnapshot): boolean { + return Boolean(access.userId && access.contextReady && access.foregroundReady && access.unlocked); +} + +function observeAccessRevocation(native: PrivacyModule): void { + stopAccessObservation?.(); + let previous = getLocalAccessSnapshot(); + stopAccessObservation = subscribeLocalAccess(() => { + const current = getLocalAccessSnapshot(); + const revoked = + !accessReady(current) || + current.userId !== previous.userId || + current.authEpoch !== previous.authEpoch || + current.unlockGeneration !== previous.unlockGeneration; + previous = current; + if (revoked) { + // This subscription can revoke visibility only. It never publishes a grant or runs a clock. + native.cover(); + } + }); +} + +const captureKeys = new Set(); + +async function updateCapture(key: string | null): Promise { + const previous = captureQueue; + const next = async () => { + try { + if (previous) { + await previous; + } + } catch { + // A failed attempt remains covered; it must not prevent a later explicit repair attempt. + } + if (Platform.OS === 'android') { + if (key !== null) { + // Expo retains keys after native rejection. Each arm must confirm protection with a fresh key. + captureKeys.add(key); + await preventScreenCaptureAsync(key); + } else { + for (const ownedKey of captureKeys) { + // eslint-disable-next-line no-await-in-loop -- finish every release before the queued rearm + await allowScreenCaptureAsync(ownedKey); + captureKeys.delete(ownedKey); + } + } + } + }; + captureQueue = next(); + await captureQueue; +} + +/** Await this before mounting authenticated windows, even when biometric locking is disabled. */ +export async function armLocalAccessPrivacy(): Promise { + armAttempt += 1; + const attempt = armAttempt; + captureReady = false; + const native = nativePrivacy(); + native.arm(); + observeAccessRevocation(native); + await updateCapture(`${CAPTURE_KEY}-${attempt}`); + if (attempt !== armAttempt) { + throw new LocalAccessDeniedError('stale'); + } + captureReady = true; + return native.getSnapshot(); +} + +/** Call only after authenticated content has unmounted, never as an unlock operation. */ +export async function disarmLocalAccessPrivacy(): Promise { + armAttempt += 1; + captureReady = false; + stopAccessObservation?.(); + stopAccessObservation = undefined; + nativePrivacy().disarm(); + await updateCapture(null); +} + +export function getLocalAccessPrivacySnapshot(): LocalAccessPrivacySnapshot { + return nativePrivacy().getSnapshot(); +} + +export function coverLocalAccessPrivacy(): void { + nativePrivacy().cover(); +} + +/** + * Use a native generation captured AFTER the shared service reconciles lifecycle and ownership. + * Native foreground events alone never authorize publication or start authentication. + * Arming observes access revocation synchronously; only this explicit handshake can uncover. + */ +export function publishLocalAccessVisibility(generation: number): boolean { + if (!captureReady || !accessReady(getLocalAccessSnapshot())) { + return false; + } + return nativePrivacy().publishVisibility(generation); +} + +/** This synchronous native assertion must accompany the shared service's immutable action lease. */ +export function assertNativeForeground(): void { + try { + if (nativePrivacy().isForegroundAllowed()) { + return; + } + } catch { + // A missing module or native failure cannot authorize an application effect. + } + throw new LocalAccessDeniedError('inactive'); +} + +export function subscribeLocalAccessPrivacy( + listener: (snapshot: LocalAccessPrivacySnapshot) => void +): () => void { + const subscription = nativePrivacy().addListener('onVisibilityChange', listener); + return () => { + subscription.remove(); + }; +} + +export function setLocalAccessPrivacyGate( + generation: number, + gate: LocalAccessPrivacyGate | null +): boolean { + return nativePrivacy().setGate(generation, gate); +} + +export function subscribeLocalAccessPrivacyGateActions(listener: (id: string) => void): () => void { + const native = nativePrivacy(); + const subscription = native.addListener('onGateAction', action => { + const current = native.getSnapshot(); + if (current.foreground && current.covered && current.generation === action.generation) { + listener(action.id); + } + }); + return () => { + subscription.remove(); + }; +} + +/** Native delivery rechecks the captured generation on the UI thread. Denial is never replayed. */ +export async function announceLocalAccessPrivacy( + message: string, + kind: 'protected' | 'gate' = 'protected' +): Promise { + try { + const native = nativePrivacy(); + const snapshot = native.getSnapshot(); + if (kind === 'protected' && snapshot.covered) { + return false; + } + return await native.announce(message, snapshot.generation, kind === 'gate'); + } catch { + return false; + } +} diff --git a/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts b/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts index 153c602ddc..b18668dbce 100644 --- a/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts +++ b/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts @@ -1,8 +1,9 @@ -import { AccessibilityInfo, Alert, Linking, Platform } from 'react-native'; +import { Alert, Linking, Platform } from 'react-native'; import * as Haptics from 'expo-haptics'; import { toast } from 'sonner-native'; import { i18n } from '@/i18n'; +import { announceForA11y } from '@/lib/a11y/announce'; import { type VoiceInputControllerSnapshot, @@ -57,7 +58,7 @@ async function fireHaptic(style: Haptics.ImpactFeedbackStyle): Promise { function announceVoiceInputListening(): void { void fireHaptic(Haptics.ImpactFeedbackStyle.Light); - AccessibilityInfo.announceForAccessibility(i18n.t('voiceInput.listening')); + announceForA11y(i18n.t('voiceInput.listening')); } export function runVoiceInputListeningFeedback( diff --git a/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts b/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts index 860c83cf35..c9c04c89cb 100644 --- a/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts +++ b/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts @@ -1,9 +1,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrivacyNativeTestModule } from '../../../modules/local-access-privacy/tests/native-test-helpers'; import { runVoiceInputListeningFeedback, showFeedback } from './use-voice-input-actions'; +const adapter = vi.hoisted((): Parameters[0] => ({ + available: true, + nativeFailure: false, + secure: false, + captureFailure: false, + captureWait: undefined, + captureEvents: [], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + delivered: [], + queue: [], + listeners: new Map(), +})); +vi.mock('expo', () => ({ + requireNativeModule: () => createPrivacyNativeTestModule(adapter), +})); +vi.mock('expo-screen-capture', () => ({ + allowScreenCaptureAsync: vi.fn(), + preventScreenCaptureAsync: vi.fn(), +})); + +function deliver() { + for (const task of adapter.queue.splice(0)) { + task(); + } +} + const hapticsMock = vi.hoisted(() => ({ impactAsync: vi.fn().mockResolvedValue(undefined) })); -const accessibilityMock = vi.hoisted(() => ({ announceForAccessibility: vi.fn() })); +const accessibilityMock = vi.hoisted(() => ({ + // A direct React Native bypass must reach the same observable speech output. + announceForAccessibility: (message: string) => { + adapter.delivered.push(message); + }, +})); const alertMock = vi.hoisted(() => ({ alert: vi.fn() })); const linkingMock = vi.hoisted(() => ({ openSettings: vi.fn() })); const toastMock = vi.hoisted(() => ({ error: vi.fn() })); @@ -29,6 +61,16 @@ vi.mock('react-native', () => ({ describe('voice input feedback side effects', () => { beforeEach(() => { vi.clearAllMocks(); + adapter.available = true; + adapter.snapshot = { + generation: 0, + armed: false, + foreground: true, + covered: false, + failed: false, + }; + adapter.delivered = []; + adapter.queue = []; }); it('presents settings feedback as an alert with a working settings action', () => { @@ -68,14 +110,90 @@ describe('voice input feedback side effects', () => { ); }); - it('announces and haptics only when entering listening', () => { - runVoiceInputListeningFeedback('idle', 'listening'); - runVoiceInputListeningFeedback('listening', 'listening'); - runVoiceInputListeningFeedback('listening', 'idle'); + it.each([false, true])( + 'announces and haptics only when entering listening (armed: %s)', + armed => { + adapter.snapshot.armed = armed; + runVoiceInputListeningFeedback('idle', 'listening'); + runVoiceInputListeningFeedback('listening', 'listening'); + runVoiceInputListeningFeedback('listening', 'idle'); + expect(hapticsMock.impactAsync).toHaveBeenCalledTimes(1); + expect(hapticsMock.impactAsync).toHaveBeenCalledWith('light'); + expect(adapter.delivered).toEqual([]); + expect(adapter.queue).toHaveLength(1); + deliver(); + expect(adapter.delivered).toEqual(['Listening...']); + } + ); + + it.each([ + { state: 'cancelled unlock', available: true, failed: false }, + { state: 'native failure', available: true, failed: true }, + { state: 'missing native module', available: false, failed: false }, + ])('keeps haptics but drops protected speech after $state without replay', state => { + adapter.available = state.available; + adapter.snapshot = { + generation: 1, + armed: true, + foreground: true, + covered: true, + failed: state.failed, + }; + runVoiceInputListeningFeedback('idle', 'listening'); + deliver(); + expect(adapter.delivered).toEqual([]); expect(hapticsMock.impactAsync).toHaveBeenCalledTimes(1); expect(hapticsMock.impactAsync).toHaveBeenCalledWith('light'); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledOnce(); - expect(accessibilityMock.announceForAccessibility).toHaveBeenCalledWith('Listening...'); + + adapter.available = true; + adapter.snapshot = { ...adapter.snapshot, generation: 2, covered: false, failed: false }; + runVoiceInputListeningFeedback('listening', 'listening'); + deliver(); + expect(adapter.delivered).toEqual([]); + expect(hapticsMock.impactAsync).toHaveBeenCalledTimes(1); + + runVoiceInputListeningFeedback('idle', 'listening'); + deliver(); + expect(adapter.delivered).toEqual(['Listening...']); + expect(hapticsMock.impactAsync).toHaveBeenCalledTimes(2); + }); + + it.each(['covered', 'unlocked'] as const)( + 'drops native-queued listening feedback when delivery runs %s after inactivity', + deliveryState => { + adapter.snapshot.armed = true; + runVoiceInputListeningFeedback('idle', 'listening'); + expect(adapter.queue).toHaveLength(1); + expect(adapter.delivered).toEqual([]); + + // The native queue outlives the JavaScript transition that requested speech. + adapter.snapshot = { ...adapter.snapshot, generation: 1, foreground: false, covered: true }; + if (deliveryState === 'unlocked') { + adapter.snapshot = { ...adapter.snapshot, generation: 2, foreground: true, covered: false }; + } + deliver(); + expect(adapter.delivered).toEqual([]); + + adapter.snapshot = { ...adapter.snapshot, generation: 2, foreground: true, covered: false }; + runVoiceInputListeningFeedback('listening', 'listening'); + deliver(); + expect(adapter.delivered).toEqual([]); + expect(hapticsMock.impactAsync).toHaveBeenCalledTimes(1); + + runVoiceInputListeningFeedback('idle', 'listening'); + deliver(); + expect(adapter.delivered).toEqual(['Listening...']); + } + ); + + it('stays silent without a listening transition', () => { + runVoiceInputListeningFeedback(null, 'idle'); + runVoiceInputListeningFeedback('idle', 'starting'); + runVoiceInputListeningFeedback('starting', 'idle'); + deliver(); + + expect(adapter.delivered).toEqual([]); + expect(hapticsMock.impactAsync).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/lib/voice-input/use-voice-input.test.ts b/apps/mobile/src/lib/voice-input/use-voice-input.test.ts index 614cd3d8ee..165b70dd3b 100644 --- a/apps/mobile/src/lib/voice-input/use-voice-input.test.ts +++ b/apps/mobile/src/lib/voice-input/use-voice-input.test.ts @@ -11,13 +11,36 @@ import { showFeedback, } from './use-voice-input-actions'; import { __resetVoiceInputLanguageTagCacheForTests } from './voice-input-language'; +import { createPrivacyNativeTestModule } from '../../../modules/local-access-privacy/tests/native-test-helpers'; + +const adapter = vi.hoisted((): Parameters[0] => ({ + available: true, + nativeFailure: false, + secure: false, + captureFailure: false, + captureWait: undefined, + captureEvents: [], + snapshot: { generation: 0, armed: false, foreground: true, covered: false, failed: false }, + delivered: [], + queue: [], + listeners: new Map(), +})); +vi.mock('expo', () => ({ + requireNativeModule: () => createPrivacyNativeTestModule(adapter), +})); +vi.mock('expo-screen-capture', () => ({ + allowScreenCaptureAsync: vi.fn(), + preventScreenCaptureAsync: vi.fn(), +})); const hapticsMock = vi.hoisted(() => ({ impactAsync: vi.fn<() => Promise>().mockResolvedValue(undefined), })); const accessibilityMock = vi.hoisted(() => ({ - announceForAccessibility: vi.fn(), + announceForAccessibility: (message: string) => { + adapter.delivered.push(message); + }, })); const alertMock = vi.hoisted(() => ({ @@ -159,6 +182,8 @@ function activeSnapshot( describe('useVoiceInput integration', () => { beforeEach(() => { vi.clearAllMocks(); + adapter.delivered = []; + adapter.queue = []; mockController.setSnapshot(idleSnapshot()); mockController.supportsOnDevice.mockReturnValue(true); voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('unset'); @@ -217,6 +242,11 @@ describe('useVoiceInput integration', () => { expect(startOptions.owner).toBe(owner); expect(startOptions.onDraftChange).toBe(onDraftChange); expect(startOptions.onFeedback).toBe(showFeedback); + for (const task of adapter.queue.splice(0)) { + task(); + } + // Starting alone cannot announce before this owner's listening transition. + expect(adapter.delivered).toEqual([]); }); it('resolves an en-DE device locale to en-US when the supported list contains en-AU and en-US', async () => {