From a7d69b360f1ba2c6493aefee14f5ca7c2f32438a Mon Sep 17 00:00:00 2001 From: Francesco-Voto Date: Mon, 7 Sep 2026 18:05:23 +0200 Subject: [PATCH 1/2] feat: migrate away from expo --- .../core/{ios => }/ReactNativeAma.podspec | 6 +- packages/core/android/build.gradle | 44 ++-- .../expo/modules/ama/ReactNativeAmaModule.kt | 170 +++++++++------ .../expo/modules/ama/ReactNativeAmaPackage.kt | 18 ++ .../expo/modules/ama/ReactNativeAmaView.kt | 30 --- .../debug/java/expo/modules/ama/highlight.kt | 8 +- .../java/expo/modules/ama/nodesGrabber.kt | 6 +- packages/core/expo-module.config.json | 12 +- packages/core/ios/NodesGrabber.swift | 6 +- packages/core/ios/ReactNativeAmaModule.m | 21 ++ packages/core/ios/ReactNativeAmaModule.swift | 200 +++++++++++------- packages/core/ios/ReactNativeAmaView.swift | 38 ---- packages/core/package.json | 22 +- packages/core/react-native.config.js | 11 + packages/core/src/ReactNativeAma.types.ts | 16 -- .../core/src/ReactNativeAmaModule.android.ts | 51 +++++ packages/core/src/ReactNativeAmaModule.ios.ts | 50 +++++ packages/core/src/ReactNativeAmaModule.ts | 26 ++- packages/core/src/ReactNativeAmaModule.web.ts | 2 +- packages/core/src/ReactNativeAmaView.tsx | 9 - packages/core/src/ReactNativeAmaView.web.tsx | 15 -- packages/core/src/index.ts | 8 +- playground/package.json | 3 +- 23 files changed, 463 insertions(+), 309 deletions(-) rename packages/core/{ios => }/ReactNativeAma.podspec (80%) create mode 100644 packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaPackage.kt delete mode 100644 packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaView.kt create mode 100644 packages/core/ios/ReactNativeAmaModule.m delete mode 100644 packages/core/ios/ReactNativeAmaView.swift create mode 100644 packages/core/react-native.config.js create mode 100644 packages/core/src/ReactNativeAmaModule.android.ts create mode 100644 packages/core/src/ReactNativeAmaModule.ios.ts delete mode 100644 packages/core/src/ReactNativeAmaView.tsx delete mode 100644 packages/core/src/ReactNativeAmaView.web.tsx diff --git a/packages/core/ios/ReactNativeAma.podspec b/packages/core/ReactNativeAma.podspec similarity index 80% rename from packages/core/ios/ReactNativeAma.podspec rename to packages/core/ReactNativeAma.podspec index 693830c5..9bb8df90 100644 --- a/packages/core/ios/ReactNativeAma.podspec +++ b/packages/core/ReactNativeAma.podspec @@ -1,6 +1,6 @@ require 'json' -package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) Pod::Spec.new do |s| s.name = 'ReactNativeAma' @@ -18,12 +18,12 @@ Pod::Spec.new do |s| s.source = { git: 'https://github.com/FormidableLabs/react-native-ama/' } s.static_framework = true - s.dependency 'ExpoModulesCore' + s.dependency 'React-Core' # Swift/Objective-C compatibility s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', } - s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" + s.source_files = "ios/**/*.{h,m,mm,swift,hpp,cpp}" end diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 11cb174b..33a22ecc 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -1,39 +1,28 @@ apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' group = 'expo.modules.ama' version = '0.1.0' -def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") -apply from: expoModulesCorePlugin -applyKotlinExpoModulesCorePlugin() -useCoreDependencies() -useExpoPublishing() - -// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. -// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. -// Most of the time, you may like to manage the Android SDK versions yourself. -def useManagedAndroidSdkVersions = false -if (useManagedAndroidSdkVersions) { - useDefaultAndroidSdkVersions() -} else { - buildscript { - // Simple helper that allows the root project to override versions declared by this library. - ext.safeExtGet = { prop, fallback -> - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback - } - } - project.android { - compileSdkVersion safeExtGet("compileSdkVersion", 34) - defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 21) - targetSdkVersion safeExtGet("targetSdkVersion", 34) - } +buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } } +repositories { + google() + mavenCentral() +} + android { namespace "expo.modules.ama" + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) versionCode 1 versionName "0.1.0" } @@ -41,3 +30,8 @@ android { abortOnError false } } + +dependencies { + // Provided by the consuming app at runtime; only needed here at compile time. + compileOnly "com.facebook.react:react-android:+" +} diff --git a/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt b/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt index b1e7422f..0000aa43 100644 --- a/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt +++ b/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt @@ -16,8 +16,13 @@ import android.widget.Checkable import android.widget.ScrollView import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.modules.core.DeviceEventManagerModule import kotlin.math.max object Constants { @@ -25,7 +30,8 @@ object Constants { var uiCheckDelay: Long = 1000 } -class ReactNativeAmaModule : Module() { +class ReactNativeAmaModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { private var isMonitoring = false private var currentDecorView: View? = null private val drawListener = ViewTreeObserver.OnDrawListener { scheduleA11yCheck() } @@ -36,84 +42,117 @@ class ReactNativeAmaModule : Module() { private var highlighter: Highlight? = null private var gap: Int = 0 private var borderWidth: Float = 6f + private var listenerCount = 0 - override fun definition() = ModuleDefinition { - Name("ReactNativeAma") + override fun getName() = "ReactNativeAmaModule" - Events("onAmaNodes", "onUIInteraction") + private fun sendEvent(eventName: String, params: Any?) { + if (listenerCount <= 0) { + return + } - Function("start") { args: Map? -> - val uiCheck = args?.get("ui") as? Boolean ?: false - Constants.uiCheckDelay = args?.get("delay") as? Long ?: Constants.uiCheckDelay - gap = (args?.get("gap") as? Number)?.toInt() ?: gap - borderWidth = (args?.get("borderWidth") as? Number)?.toFloat() ?: borderWidth + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, params) + } - if (highlighter == null) { - highlighter = Highlight(appContext) - } + // Required by RN's NativeEventEmitter, which calls these to track active JS listeners. + @ReactMethod + fun addListener(eventName: String) { + listenerCount++ + } - if (!isMonitoring) { - Logger.info("start", "👀 Start Monitoring 👀" + args.toString()) + @ReactMethod + fun removeListeners(count: Int) { + listenerCount = max(0, listenerCount - count) + } - a11yChecker = NodesGrabber(appContext) + @ReactMethod + fun start(options: ReadableMap?) { + val uiCheck = options?.takeIf { it.hasKey("ui") }?.getBoolean("ui") ?: false + Constants.uiCheckDelay = + options?.takeIf { it.hasKey("delay") }?.getDouble("delay")?.toLong() + ?: Constants.uiCheckDelay + gap = options?.takeIf { it.hasKey("gap") }?.getDouble("gap")?.toInt() ?: gap + borderWidth = + options?.takeIf { it.hasKey("borderWidth") }?.getDouble("borderWidth")?.toFloat() + ?: borderWidth + + if (highlighter == null) { + highlighter = Highlight(reactApplicationContext) + } - val activity = getCurrentActivity() + if (!isMonitoring) { + Logger.info("start", "👀 Start Monitoring 👀" + options.toString()) - if (uiCheck) { - attachWindowTapProbe(activity) - } + a11yChecker = NodesGrabber(reactApplicationContext) - activity?.window?.decorView?.let { - currentDecorView = it - it.viewTreeObserver.addOnDrawListener(drawListener) - } + val activity = getCurrentActivity() + + if (uiCheck) { + attachWindowTapProbe(activity) + } - isMonitoring = true + activity?.window?.decorView?.let { + currentDecorView = it + it.viewTreeObserver.addOnDrawListener(drawListener) } + + isMonitoring = true } + } - Function("stop") { - if (isMonitoring) { - currentDecorView?.let { it.viewTreeObserver.removeOnDrawListener(drawListener) } + @ReactMethod + fun stop() { + if (isMonitoring) { + currentDecorView?.let { it.viewTreeObserver.removeOnDrawListener(drawListener) } - isMonitoring = false - } + isMonitoring = false } + } - AsyncFunction("highlight") { viewId: Int, mode: String, hexColor: String, issueCount: Int -> - val activity = appContext.activityProvider?.currentActivity ?: return@AsyncFunction null - val root = activity.window.decorView as? ViewGroup ?: return@AsyncFunction null - val target = root.findViewById(viewId) ?: return@AsyncFunction null - - val scroll = - generateSequence(target.parent) { (it as? View)?.parent } - .filterIsInstance() - .firstOrNull() - scroll?.let { sv -> - val frame = Rect().apply { target.getDrawingRect(this) } - sv.offsetDescendantRectToMyCoords(target, frame) - - val topVisible = frame.top >= 0 - val bottomVisible = frame.bottom <= sv.height - - if (!topVisible || !bottomVisible) { - val mPx = (10 * activity.resources.displayMetrics.density).toInt() - val scrollToY = - when { - frame.top < 0 -> max(0, frame.top - mPx) - frame.bottom > sv.height -> frame.bottom - sv.height + mPx - else -> sv.scrollY - } - sv.scrollTo(sv.scrollX, scrollToY) - } - } + @ReactMethod + fun highlight(viewId: Int, mode: String, hexColor: String, issueCount: Int, promise: Promise) { + val activity = reactApplicationContext.currentActivity + val root = activity?.window?.decorView as? ViewGroup + val target = root?.findViewById(viewId) - highlighter?.highlight(viewId, mode, hexColor, gap, borderWidth, issueCount) + if (activity == null || root == null || target == null) { + promise.resolve(null) + return + } - target.getGlobalDpBounds(root) + val scroll = + generateSequence(target.parent) { (it as? View)?.parent } + .filterIsInstance() + .firstOrNull() + scroll?.let { sv -> + val frame = Rect().apply { target.getDrawingRect(this) } + sv.offsetDescendantRectToMyCoords(target, frame) + + val topVisible = frame.top >= 0 + val bottomVisible = frame.bottom <= sv.height + + if (!topVisible || !bottomVisible) { + val mPx = (10 * activity.resources.displayMetrics.density).toInt() + val scrollToY = + when { + frame.top < 0 -> max(0, frame.top - mPx) + frame.bottom > sv.height -> frame.bottom - sv.height + mPx + else -> sv.scrollY + } + sv.scrollTo(sv.scrollX, scrollToY) + } } - AsyncFunction("clearHighlight") { viewId: Int -> highlighter?.clearHighlight(viewId) } + highlighter?.highlight(viewId, mode, hexColor, gap, borderWidth, issueCount) + + promise.resolve(Arguments.fromList(target.getGlobalDpBounds(root))) + } + + @ReactMethod + fun clearHighlight(viewId: Int) { + highlighter?.clearHighlight(viewId) } private fun attachWindowTapProbe(activity: Activity?) { @@ -176,7 +215,7 @@ class ReactNativeAmaModule : Module() { } private fun getCurrentActivity(): Activity? { - return appContext.activityProvider?.currentActivity + return reactApplicationContext.currentActivity } private fun scheduleA11yCheck() { @@ -191,7 +230,7 @@ class ReactNativeAmaModule : Module() { val nodesWithStringKeys = issues.mapKeys { it.key.toString() }.mapValues { it.value } if (send) { - sendEvent("onAmaNodes", nodesWithStringKeys) + sendEvent("onAmaNodes", Arguments.makeNativeMap(nodesWithStringKeys)) } return nodesWithStringKeys @@ -289,7 +328,10 @@ class ReactNativeAmaModule : Module() { ) } - sendEvent("onUIInteraction", event) + sendEvent( + "onUIInteraction", + Arguments.fromBundle(event) + ) } }, Constants.uiCheckDelay diff --git a/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaPackage.kt b/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaPackage.kt new file mode 100644 index 00000000..3ceb8de5 --- /dev/null +++ b/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaPackage.kt @@ -0,0 +1,18 @@ +package expo.modules.ama + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class ReactNativeAmaPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(ReactNativeAmaModule(reactContext)) + } + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> { + return emptyList() + } +} diff --git a/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaView.kt b/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaView.kt deleted file mode 100644 index d6fb1c0a..00000000 --- a/packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaView.kt +++ /dev/null @@ -1,30 +0,0 @@ -package expo.modules.ama - -import android.content.Context -import android.webkit.WebView -import android.webkit.WebViewClient -import expo.modules.kotlin.AppContext -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.views.ExpoView - -class ReactNativeAmaView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { - // Creates and initializes an event dispatcher for the `onLoad` event. - // The name of the event is inferred from the value and needs to match the event name defined in the module. - private val onLoad by EventDispatcher() - - // Defines a WebView that will be used as the root subview. - internal val webView = WebView(context).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView, url: String) { - // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript. - onLoad(mapOf("url" to url)) - } - } - } - - init { - // Adds the WebView to the view hierarchy. - addView(webView) - } -} diff --git a/packages/core/android/src/debug/java/expo/modules/ama/highlight.kt b/packages/core/android/src/debug/java/expo/modules/ama/highlight.kt index 55df4668..c71224ec 100644 --- a/packages/core/android/src/debug/java/expo/modules/ama/highlight.kt +++ b/packages/core/android/src/debug/java/expo/modules/ama/highlight.kt @@ -7,10 +7,10 @@ import android.graphics.drawable.LayerDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape import android.view.View -import expo.modules.kotlin.AppContext +import com.facebook.react.bridge.ReactApplicationContext import java.util.concurrent.ConcurrentHashMap -class Highlight(private val appContext: AppContext) { +class Highlight(private val reactContext: ReactApplicationContext) { private val originalBackgrounds = ConcurrentHashMap() /** @@ -19,7 +19,7 @@ class Highlight(private val appContext: AppContext) { * @param mode "background" | "border" | "both" */ fun highlight(viewId: Int, mode: String, hexColor: String, gap: Int = 0, lineWidth: Float = 6f, issueCount: Int = 1) { - val activity: Activity = appContext.currentActivity ?: return + val activity: Activity = reactContext.currentActivity ?: return val color = hexColor.toColor() activity.runOnUiThread { @@ -41,7 +41,7 @@ class Highlight(private val appContext: AppContext) { } fun clearHighlight(viewId: Int) { - val activity: Activity = appContext.currentActivity ?: return + val activity: Activity = reactContext.currentActivity ?: return activity.runOnUiThread { val view = activity.findViewById(viewId) ?: return@runOnUiThread diff --git a/packages/core/android/src/debug/java/expo/modules/ama/nodesGrabber.kt b/packages/core/android/src/debug/java/expo/modules/ama/nodesGrabber.kt index 1f6da5f9..de7146e9 100644 --- a/packages/core/android/src/debug/java/expo/modules/ama/nodesGrabber.kt +++ b/packages/core/android/src/debug/java/expo/modules/ama/nodesGrabber.kt @@ -15,7 +15,7 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.core.view.accessibility.AccessibilityNodeInfoCompat -import expo.modules.kotlin.AppContext +import com.facebook.react.bridge.ReactApplicationContext data class NodePayload( val type: String, @@ -58,8 +58,8 @@ enum class NodeType { Image } -class NodesGrabber(private val appContext: AppContext) { - val activity = appContext.activityProvider?.currentActivity +class NodesGrabber(private val reactContext: ReactApplicationContext) { + val activity = reactContext.currentActivity private var nodesToCheck = mutableMapOf() private lateinit var rootView: View diff --git a/packages/core/expo-module.config.json b/packages/core/expo-module.config.json index 7644c662..d167a232 100644 --- a/packages/core/expo-module.config.json +++ b/packages/core/expo-module.config.json @@ -1,9 +1,5 @@ { - "platforms": ["apple", "android", "web"], - "apple": { - "modules": ["ReactNativeAmaModule"] - }, - "android": { - "modules": ["expo.modules.ama.ReactNativeAmaModule"] - } -} + "platforms": [ + "web" + ] +} \ No newline at end of file diff --git a/packages/core/ios/NodesGrabber.swift b/packages/core/ios/NodesGrabber.swift index eceac76b..543b0c8d 100644 --- a/packages/core/ios/NodesGrabber.swift +++ b/packages/core/ios/NodesGrabber.swift @@ -1,4 +1,3 @@ -import ExpoModulesCore import UIKit public struct NodePayload: Equatable { @@ -38,12 +37,9 @@ public struct NodePayload: Equatable { } public class NodesGrabber { - private let appContext: AppContext private var nodesToCheck: [Int: NodePayload] = [:] - public init(appContext: AppContext) { - self.appContext = appContext - } + public init() {} public func getNodesToCheck(on rootView: UIView?) -> ( nodes: [Int: NodePayload], send: Bool diff --git a/packages/core/ios/ReactNativeAmaModule.m b/packages/core/ios/ReactNativeAmaModule.m new file mode 100644 index 00000000..3ec841f9 --- /dev/null +++ b/packages/core/ios/ReactNativeAmaModule.m @@ -0,0 +1,21 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(ReactNativeAmaModule, RCTEventEmitter) + +RCT_EXTERN_METHOD(start:(NSDictionary *)options) + +RCT_EXTERN_METHOD(stop) + +RCT_EXTERN_METHOD( + highlight:(nonnull NSNumber *)viewId + mode:(NSString *)mode + hexColor:(NSString *)hexColor + issueCount:(nonnull NSNumber *)issueCount + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject +) + +RCT_EXTERN_METHOD(clearHighlight:(nonnull NSNumber *)viewId) + +@end diff --git a/packages/core/ios/ReactNativeAmaModule.swift b/packages/core/ios/ReactNativeAmaModule.swift index 0b6c2f7b..9f4f8efa 100644 --- a/packages/core/ios/ReactNativeAmaModule.swift +++ b/packages/core/ios/ReactNativeAmaModule.swift @@ -1,12 +1,13 @@ #if DEBUG -import ExpoModulesCore +import React import UIKit struct Constants { static let debounce: TimeInterval = 2.0 } -public class ReactNativeAmaModule: Module { +@objc(ReactNativeAmaModule) +public class ReactNativeAmaModule: RCTEventEmitter { private var isMonitoring = false private var currentDecorView: UIView? private var displayLink: CADisplayLink? @@ -25,72 +26,99 @@ public class ReactNativeAmaModule: Module { private var gap: CGFloat = 0 private var borderWidth: CGFloat = 3 - public func definition() -> ModuleDefinition { - Name("ReactNativeAma") + private var hasListeners = false - Events("onAmaNodes", "onUIInteraction") + @objc override public static func requiresMainQueueSetup() -> Bool { + return true + } - Function("start") { (options: [String: Any]?) in - let uiCheck = options?["ui"] as? Bool ?? false - uiCheckDelay = options?["delay"] as? Int ?? uiCheckDelay - gap = options?["gap"] as? CGFloat ?? gap - borderWidth = options?["borderWidth"] as? CGFloat ?? borderWidth + @objc override public func supportedEvents() -> [String]! { + return ["onAmaNodes", "onUIInteraction"] + } - guard !isMonitoring else { return } + @objc override public func startObserving() { + hasListeners = true + } - Logger.info("start", "👀 Start Monitoring 👀") + @objc override public func stopObserving() { + hasListeners = false + } - self.a11yChecker = NodesGrabber(appContext: self.appContext!) - self.highlighter = Highlight() + @objc(start:) + public func start(_ options: [String: Any]?) { + let uiCheck = options?["ui"] as? Bool ?? false + uiCheckDelay = options?["delay"] as? Int ?? uiCheckDelay + gap = options?["gap"] as? CGFloat ?? gap + borderWidth = options?["borderWidth"] as? CGFloat ?? borderWidth - isMonitoring = true + guard !isMonitoring else { return } - DispatchQueue.main.async { - guard - let viewController = self.appContext?.utilities? - .currentViewController(), - let decorView = viewController.view, - let currentView = viewController.view, - let window = currentView.window - else { - Logger.info("start", "DEBUG guard failed — no viewController/decorView/window yet") - return - } + Logger.info("start", "👀 Start Monitoring 👀") - self.currentDecorView = decorView - self.setupDisplayLink() + self.a11yChecker = NodesGrabber() + self.highlighter = Highlight() - if uiCheck { - self.attachWindowTapProbe() - } + isMonitoring = true + + DispatchQueue.main.async { + guard + let viewController = RCTPresentedViewController(), + let decorView = viewController.view, + let currentView = viewController.view, + let window = currentView.window + else { + Logger.info("start", "DEBUG guard failed — no viewController/decorView/window yet") + return } - } - Function("stop") { - guard isMonitoring else { return } + self.currentDecorView = decorView + self.setupDisplayLink() - if let recognizer = self.windowTapRecognizer, - let window = UIApplication.shared.currentKeyWindow - { - window.removeGestureRecognizer(recognizer) + if uiCheck { + self.attachWindowTapProbe() } + } + } - self.windowTapRecognizer = nil - self.isMonitoring = false + @objc(stop) + public func stop() { + guard isMonitoring else { return } - DispatchQueue.main.async { - self.displayLink?.invalidate() - self.displayLink = nil - } + if let recognizer = self.windowTapRecognizer, + let window = UIApplication.shared.currentKeyWindow + { + window.removeGestureRecognizer(recognizer) } - AsyncFunction("highlight") { - (viewId: Int, mode: String, hexColor: String, issueCount: Int) async -> [Double]? in - guard - let root = self.currentDecorView, - let target = await root.viewWithTag(viewId) - else { - return nil + self.windowTapRecognizer = nil + self.isMonitoring = false + + DispatchQueue.main.async { + self.displayLink?.invalidate() + self.displayLink = nil + } + } + + @objc(highlight:mode:hexColor:issueCount:resolver:rejecter:) + public func highlight( + _ viewId: NSNumber, + mode: String, + hexColor: String, + issueCount: NSNumber, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + Task { + guard let root = self.currentDecorView else { + resolver(nil) + return + } + + let target = await MainActor.run { root.viewWithTag(viewId.intValue) } + + guard let target = target else { + resolver(nil) + return } await MainActor.run { @@ -109,9 +137,9 @@ public class ReactNativeAmaModule: Module { view: target, mode: mode, hexColor: hexColor, - gap: gap ?? 0, - lineWidth: borderWidth ?? 3, - issueCount: issueCount + gap: self.gap, + lineWidth: self.borderWidth, + issueCount: issueCount.intValue ) } @@ -119,18 +147,19 @@ public class ReactNativeAmaModule: Module { target.convert(target.bounds, to: nil) } - return [ + resolver([ bounds.origin.x, bounds.origin.y, bounds.width, bounds.height, - ] + ]) } + } - AsyncFunction("clearHighlight") { (viewId: Int) in - await MainActor.run { - self.highlighter?.clearHighlight(viewId: viewId) - } + @objc(clearHighlight:) + public func clearHighlight(_ viewId: NSNumber) { + DispatchQueue.main.async { + self.highlighter?.clearHighlight(viewId: viewId.intValue) } } @@ -174,7 +203,7 @@ public class ReactNativeAmaModule: Module { guard let result = a11yChecker?.getNodesToCheck(on: currentDecorView) else { return } - if let shouldSend = result.send as? Bool, shouldSend { + if let shouldSend = result.send as? Bool, shouldSend, hasListeners { // Since result.nodes is not optional, we can access it directly here. let nodes = result.nodes let nodesWithStringKeys = Dictionary( @@ -184,8 +213,8 @@ public class ReactNativeAmaModule: Module { ) sendEvent( - "onAmaNodes", - nodesWithStringKeys + withName: "onAmaNodes", + body: nodesWithStringKeys ) } } @@ -324,8 +353,8 @@ extension ReactNativeAmaModule { // window's own hitTest stops at the SwiftUI `UIViewControllerWrapperView` // bridge (e.g. Expo Router native tabs) and never recurses into the // Fabric view tree mounted inside it. Starting from `currentDecorView` - // (the actual RN root, already resolved via appContext.utilities) skips - // that boundary entirely. + // (the actual RN root, already resolved via RCTPresentedViewController()) + // skips that boundary entirely. guard let decorView = currentDecorView else { return } // Use the location captured eagerly in touchesEnded, not @@ -414,7 +443,10 @@ extension ReactNativeAmaModule { ] isCheckScheduled = false - self.sendEvent("onUIInteraction", payload) + + if self.hasListeners { + self.sendEvent(withName: "onUIInteraction", body: payload) + } } } } @@ -558,11 +590,37 @@ extension ReactNativeAmaModule { } } #else -import ExpoModulesCore +import React + +@objc(ReactNativeAmaModule) +public class ReactNativeAmaModule: RCTEventEmitter { + @objc override public static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc override public func supportedEvents() -> [String]! { + return [] + } + + @objc(start:) + public func start(_ options: [String: Any]?) {} + + @objc(stop) + public func stop() {} + + @objc(highlight:mode:hexColor:issueCount:resolver:rejecter:) + public func highlight( + _ viewId: NSNumber, + mode: String, + hexColor: String, + issueCount: NSNumber, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + resolver(nil) + } -public class ReactNativeAmaModule: Module { - public func definition() -> ModuleDefinition { - Name("ReactNativeAma") - } + @objc(clearHighlight:) + public func clearHighlight(_ viewId: NSNumber) {} } #endif diff --git a/packages/core/ios/ReactNativeAmaView.swift b/packages/core/ios/ReactNativeAmaView.swift deleted file mode 100644 index 68fa92fa..00000000 --- a/packages/core/ios/ReactNativeAmaView.swift +++ /dev/null @@ -1,38 +0,0 @@ -import ExpoModulesCore -import WebKit - -// This view will be used as a native component. Make sure to inherit from `ExpoView` -// to apply the proper styling (e.g. border radius and shadows). -class ReactNativeAmaView: ExpoView { - let webView = WKWebView() - let onLoad = EventDispatcher() - var delegate: WebViewDelegate? - - required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - clipsToBounds = true - delegate = WebViewDelegate { url in - self.onLoad(["url": url]) - } - webView.navigationDelegate = delegate - addSubview(webView) - } - - override func layoutSubviews() { - webView.frame = bounds - } -} - -class WebViewDelegate: NSObject, WKNavigationDelegate { - let onUrlChange: (String) -> Void - - init(onUrlChange: @escaping (String) -> Void) { - self.onUrlChange = onUrlChange - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) { - if let url = webView.url { - onUrlChange(url.absoluteString) - } - } -} diff --git a/packages/core/package.json b/packages/core/package.json index 88f12ccb..545211ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,20 +34,18 @@ "types": "./dist/ReactNativeAmaModule.d.ts", "default": "./dist/ReactNativeAmaModule.js" }, - "./ReactNativeAmaView": { - "types": "./dist/ReactNativeAmaView.d.ts", - "default": "./dist/ReactNativeAmaView.js" - }, "./ReactNativeAma.types": { "types": "./dist/ReactNativeAma.types.d.ts", "default": "./dist/ReactNativeAma.types.js" - } + }, + "./package.json": "./package.json" }, "files": [ "src", "dist", "ama.config.json", "expo-module.config.json", + "ReactNativeAma.podspec", "ios", "android", "!android/build" @@ -59,9 +57,15 @@ "postinstall": "node ./src/postinstall.js" }, "peerDependencies": { + "expo-modules-core": "*", "react": "*", "react-native": "*" }, + "peerDependenciesMeta": { + "expo-modules-core": { + "optional": true + } + }, "keywords": [ "react-native", "a11y", @@ -96,10 +100,10 @@ "collectCoverageFrom": [ "src/**/*.{ts,tsx}", "!src/index.ts", - "!src/ReactNativeAmaModule.ts", + "!src/ReactNativeAmaModule.ios.ts", + "!src/ReactNativeAmaModule.android.ts", "!src/ReactNativeAmaModule.web.ts", - "!src/ReactNativeAmaView.tsx", - "!src/ReactNativeAmaView.web.tsx", + "!src/ReactNativeAmaModule.ts", "!src/ReactNativeAma.types.ts" ], "coverageThreshold": { @@ -124,4 +128,4 @@ }, "verbose": true } -} +} \ No newline at end of file diff --git a/packages/core/react-native.config.js b/packages/core/react-native.config.js new file mode 100644 index 00000000..d7e8beb6 --- /dev/null +++ b/packages/core/react-native.config.js @@ -0,0 +1,11 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import expo.modules.ama.ReactNativeAmaPackage;', + packageInstance: 'new ReactNativeAmaPackage()', + }, + }, + }, +}; diff --git a/packages/core/src/ReactNativeAma.types.ts b/packages/core/src/ReactNativeAma.types.ts index 8b50fd6c..c23f7191 100644 --- a/packages/core/src/ReactNativeAma.types.ts +++ b/packages/core/src/ReactNativeAma.types.ts @@ -1,9 +1,3 @@ -import type { StyleProp, ViewStyle } from 'react-native'; - -export type OnLoadEventPayload = { - url: string; -}; - export type AmaNode = { type: 'Pressable' | 'Text' | 'TextInput' | 'Image'; viewId: number; @@ -58,13 +52,3 @@ export type ReactNativeAmaModuleEvents = { onAmaNodes: (nodes: AmaNodes) => void; onUIInteraction: (nodes: AmaUiSnapshotsData) => void; }; - -export type ChangeEventPayload = { - value: string; -}; - -export type ReactNativeAmaViewProps = { - url: string; - onLoad: (event: { nativeEvent: OnLoadEventPayload }) => void; - style?: StyleProp; -}; diff --git a/packages/core/src/ReactNativeAmaModule.android.ts b/packages/core/src/ReactNativeAmaModule.android.ts new file mode 100644 index 00000000..5ed7e79d --- /dev/null +++ b/packages/core/src/ReactNativeAmaModule.android.ts @@ -0,0 +1,51 @@ +import { NativeEventEmitter, NativeModules } from 'react-native'; +import { ReactNativeAmaModuleEvents } from './ReactNativeAma.types'; +import { Position } from './internals/types'; + +type ReactNativeAmaNativeModule = { + start(config?: any): void; + stop(): void; + highlight( + viewId: number, + mode: 'background' | 'border' | 'both', + color: string, + issueCount: number, + ): Promise; + clearHighlight(viewId: number): void; +}; + +// The legacy/classic RN bridge module registered by ReactNativeAmaPackage.kt +// (see packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt +// and ReactNativeAmaPackage.kt). +const ReactNativeAma: ReactNativeAmaNativeModule = + NativeModules.ReactNativeAmaModule; + +const eventEmitter = new NativeEventEmitter(NativeModules.ReactNativeAmaModule); + +const ReactNativeAmaModule = { + start(config?: any): void { + ReactNativeAma.start(config); + }, + stop(): void { + ReactNativeAma.stop(); + }, + highlight( + viewId: number, + mode: 'background' | 'border' | 'both', + color: string, + issueCount: number, + ): Promise { + return ReactNativeAma.highlight(viewId, mode, color, issueCount); + }, + clearHighlight(viewId: number): void { + ReactNativeAma.clearHighlight(viewId); + }, + addListener( + eventName: EventName, + listener: ReactNativeAmaModuleEvents[EventName], + ) { + return eventEmitter.addListener(eventName, listener as (...args: any[]) => void); + }, +}; + +export default ReactNativeAmaModule; diff --git a/packages/core/src/ReactNativeAmaModule.ios.ts b/packages/core/src/ReactNativeAmaModule.ios.ts new file mode 100644 index 00000000..478667d2 --- /dev/null +++ b/packages/core/src/ReactNativeAmaModule.ios.ts @@ -0,0 +1,50 @@ +import { NativeEventEmitter, NativeModules } from 'react-native'; +import { ReactNativeAmaModuleEvents } from './ReactNativeAma.types'; +import { Position } from './internals/types'; + +type ReactNativeAmaNativeModule = { + start(config?: any): void; + stop(): void; + highlight( + viewId: number, + mode: 'background' | 'border' | 'both', + color: string, + issueCount: number, + ): Promise; + clearHighlight(viewId: number): void; +}; + +// The legacy/classic RN bridge module registered by ReactNativeAmaModule.m +// (see packages/core/ios/ReactNativeAmaModule.m and .swift). +const ReactNativeAma: ReactNativeAmaNativeModule = + NativeModules.ReactNativeAmaModule; + +const eventEmitter = new NativeEventEmitter(NativeModules.ReactNativeAmaModule); + +const ReactNativeAmaModule = { + start(config?: any): void { + ReactNativeAma.start(config); + }, + stop(): void { + ReactNativeAma.stop(); + }, + highlight( + viewId: number, + mode: 'background' | 'border' | 'both', + color: string, + issueCount: number, + ): Promise { + return ReactNativeAma.highlight(viewId, mode, color, issueCount); + }, + clearHighlight(viewId: number): void { + ReactNativeAma.clearHighlight(viewId); + }, + addListener( + eventName: EventName, + listener: ReactNativeAmaModuleEvents[EventName], + ) { + return eventEmitter.addListener(eventName, listener as (...args: any[]) => void); + }, +}; + +export default ReactNativeAmaModule; diff --git a/packages/core/src/ReactNativeAmaModule.ts b/packages/core/src/ReactNativeAmaModule.ts index 5a3e59db..797889c8 100644 --- a/packages/core/src/ReactNativeAmaModule.ts +++ b/packages/core/src/ReactNativeAmaModule.ts @@ -1,8 +1,7 @@ -import { NativeModule, requireNativeModule } from 'expo'; import { ReactNativeAmaModuleEvents } from './ReactNativeAma.types'; import { Position } from './internals/types'; -declare class ReactNativeAmaModule extends NativeModule { +type ReactNativeAmaModuleType = { start(config?: any): void; stop(): void; highlight( @@ -12,7 +11,24 @@ declare class ReactNativeAmaModule extends NativeModule; clearHighlight(viewId: number): void; -} + addListener( + eventName: EventName, + listener: ReactNativeAmaModuleEvents[EventName], + ): { remove(): void }; +}; -// This call loads the native module object from the JSI. -export default requireNativeModule('ReactNativeAma'); +const unsupported = (): never => { + throw new Error( + 'ReactNativeAmaModule: no platform-specific implementation was resolved for this platform.', + ); +}; + +const ReactNativeAmaModule: ReactNativeAmaModuleType = { + start: unsupported, + stop: unsupported, + highlight: unsupported, + clearHighlight: unsupported, + addListener: unsupported, +}; + +export default ReactNativeAmaModule; diff --git a/packages/core/src/ReactNativeAmaModule.web.ts b/packages/core/src/ReactNativeAmaModule.web.ts index 455c24a8..6c9c8a83 100644 --- a/packages/core/src/ReactNativeAmaModule.web.ts +++ b/packages/core/src/ReactNativeAmaModule.web.ts @@ -1,4 +1,4 @@ -import { NativeModule, registerWebModule } from 'expo'; +import { NativeModule, registerWebModule } from 'expo-modules-core'; import { AmaNodes, ReactNativeAmaModuleEvents } from './ReactNativeAma.types'; class ReactNativeAmaModule extends NativeModule { diff --git a/packages/core/src/ReactNativeAmaView.tsx b/packages/core/src/ReactNativeAmaView.tsx deleted file mode 100644 index 60f07cd5..00000000 --- a/packages/core/src/ReactNativeAmaView.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { requireNativeView } from 'expo'; -import * as React from 'react'; -import { ReactNativeAmaViewProps } from './ReactNativeAma.types'; - -const NativeView: React.ComponentType = requireNativeView('ReactNativeAma'); - -export default function ReactNativeAmaView(props: ReactNativeAmaViewProps) { - return ; -} diff --git a/packages/core/src/ReactNativeAmaView.web.tsx b/packages/core/src/ReactNativeAmaView.web.tsx deleted file mode 100644 index df9deb70..00000000 --- a/packages/core/src/ReactNativeAmaView.web.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; -import { ReactNativeAmaViewProps } from './ReactNativeAma.types'; - -export default function ReactNativeAmaView(props: ReactNativeAmaViewProps) { - return ( -
-