From 91362380f3f09d44c4753085fa9e50f7af2c098a Mon Sep 17 00:00:00 2001 From: Bright Etornam Sunu Date: Wed, 8 Jul 2026 19:34:21 +0000 Subject: [PATCH 1/3] Fix upstream issues: LateInit handlers (#8), add updateNode (#6), widen deps (#26, #29) - ARSessionManager.onPlaneOrPointTap/onPlaneDetected: late -> nullable, fixes 'LateInitializationError: Field onPlaneDetected has not been initialized' (#8) - ARObjectManager.updateNode(): add the documented transform-sync method that was referenced by the maintainer but never existed; reuses the native 'transformationChanged' handler (#6) - pubspec: widen permission_handler to >=11.3.1 <13.0.0 (#26); bump Dart SDK upper bound to <4.0.0 (#29, hygiene) - tests: regression tests for #8 and #6 --- lib/managers/ar_object_manager.dart | 12 +++++++ lib/managers/ar_session_manager.dart | 8 ++--- pubspec.yaml | 4 +-- test/ar_flutter_plugin_test.dart | 50 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/lib/managers/ar_object_manager.dart b/lib/managers/ar_object_manager.dart index 608ba464..f839632d 100644 --- a/lib/managers/ar_object_manager.dart +++ b/lib/managers/ar_object_manager.dart @@ -145,4 +145,16 @@ class ARObjectManager { removeNode(ARNode node) { _channel.invokeMethod('removeNode', {'name': node.name}); } + + /// Pushes the given node's current transformation to the underlying AR scene. + /// Call this after programmatically changing a node's [ARNode.transform], + /// [ARNode.position], [ARNode.rotation], [ARNode.scale] or [ARNode.eulerAngles] + /// to synchronize the change with the native AR engine. + Future updateNode(ARNode node) async { + await _channel.invokeMethod('transformationChanged', { + 'name': node.name, + 'transformation': + MatrixValueNotifierConverter().toJson(node.transformNotifier), + }); + } } diff --git a/lib/managers/ar_session_manager.dart b/lib/managers/ar_session_manager.dart index deee08ba..ceae7ae0 100644 --- a/lib/managers/ar_session_manager.dart +++ b/lib/managers/ar_session_manager.dart @@ -29,10 +29,10 @@ class ARSessionManager { final PlaneDetectionConfig planeDetectionConfig; /// Receives hit results from user taps with tracked planes or feature points - late ARHitResultHandler onPlaneOrPointTap; + ARHitResultHandler? onPlaneOrPointTap; /// Receives total number of Planes when a plane is detected and added to the view - late ARPlaneResultHandler onPlaneDetected; + ARPlaneResultHandler? onPlaneDetected; /// Callback that is triggered once error is triggered ErrorHandler? onError; @@ -158,13 +158,13 @@ class ARSessionManager { final hitTestResults = serializedHitTestResults.map((e) { return ARHitTestResult.fromJson(e); }).toList(); - onPlaneOrPointTap(hitTestResults); + onPlaneOrPointTap!(hitTestResults); } break; case 'onPlaneDetected': if (onPlaneDetected != null) { final planeCountResult = call.arguments as int; - onPlaneDetected(planeCountResult); + onPlaneDetected!(planeCountResult); } break; case 'dispose': diff --git a/pubspec.yaml b/pubspec.yaml index 85b13936..e0a59e11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,13 +4,13 @@ version: 0.0.3 repository: https://github.com/hlefe/ar_flutter_plugin_2 environment: - sdk: ">=2.16.1 <3.0.0" + sdk: ">=2.16.1 <4.0.0" flutter: ">=1.20.0" dependencies: flutter: sdk: flutter - permission_handler: ^11.3.1 + permission_handler: ">=11.3.1 <13.0.0" vector_math: ^2.1.1 json_annotation: ^4.9.0 geolocator: ^12.0.0 diff --git a/test/ar_flutter_plugin_test.dart b/test/ar_flutter_plugin_test.dart index b229d62d..b4d56e29 100644 --- a/test/ar_flutter_plugin_test.dart +++ b/test/ar_flutter_plugin_test.dart @@ -1,6 +1,12 @@ +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ar_flutter_plugin_2/ar_flutter_plugin.dart'; +import 'package:ar_flutter_plugin_2/managers/ar_session_manager.dart'; +import 'package:ar_flutter_plugin_2/managers/ar_object_manager.dart'; +import 'package:ar_flutter_plugin_2/datatypes/config_planedetection.dart'; +import 'package:ar_flutter_plugin_2/datatypes/node_types.dart'; +import 'package:ar_flutter_plugin_2/models/ar_node.dart'; void main() { const MethodChannel channel = MethodChannel('ar_flutter_plugin_2'); @@ -20,4 +26,48 @@ void main() { test('getPlatformVersion', () async { expect(await ArFlutterPlugin.platformVersion, '42'); }); + + // Regression test for upstream issue #8: the session event handlers used to + // be declared `late` and non-nullable, so reading them before the app + // assigned one threw `LateInitializationError: Field 'onPlaneDetected' has + // not been initialized.` They must now be nullable and default to null. + testWidgets('session event handlers are null-safe when unset (issue #8)', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final context = tester.element(find.byType(SizedBox)); + + final sessionManager = + ARSessionManager(0, context, PlaneDetectionConfig.none); + + expect(sessionManager.onPlaneOrPointTap, isNull); + expect(sessionManager.onPlaneDetected, isNull); + }); + + // Regression test for upstream issue #6: `updateNode` did not exist even + // though it was the documented way to sync transform changes. It must push a + // `transformationChanged` call (the method the native side already handles). + test('updateNode pushes transformationChanged to the object channel (issue #6)', + () async { + const objectChannel = MethodChannel('arobjects_0'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = []; + messenger.setMockMethodCallHandler(objectChannel, (call) async { + calls.add(call); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(objectChannel, null)); + + final objectManager = ARObjectManager(0); + final node = ARNode( + type: NodeType.localGLTF2, uri: 'model.gltf', name: 'myNode'); + + await objectManager.updateNode(node); + + expect(calls.single.method, 'transformationChanged'); + final args = calls.single.arguments as Map; + expect(args['name'], 'myNode'); + expect(args['transformation'], isA>()); + expect((args['transformation'] as List).length, 16); + }); } From 5c165a20d027d341b0eb7049c82c1e19949520fc Mon Sep 17 00:00:00 2001 From: Bright Etornam Sunu Date: Wed, 8 Jul 2026 19:39:16 +0000 Subject: [PATCH 2/3] Make updateNode fire-and-forget to avoid awaiting a reply Android may never send Android's handleTransformNode only calls result.* when handlePans||handleRotation is enabled; both default to false, so an awaited transformationChanged would hang. Mirror removeNode (void, no await). Document the Android gesture-flag requirement. --- lib/managers/ar_object_manager.dart | 10 ++++++++-- test/ar_flutter_plugin_test.dart | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/managers/ar_object_manager.dart b/lib/managers/ar_object_manager.dart index f839632d..b7608a4a 100644 --- a/lib/managers/ar_object_manager.dart +++ b/lib/managers/ar_object_manager.dart @@ -150,8 +150,14 @@ class ARObjectManager { /// Call this after programmatically changing a node's [ARNode.transform], /// [ARNode.position], [ARNode.rotation], [ARNode.scale] or [ARNode.eulerAngles] /// to synchronize the change with the native AR engine. - Future updateNode(ARNode node) async { - await _channel.invokeMethod('transformationChanged', { + /// + /// Fire-and-forget (like [removeNode]): it is intentionally not awaitable + /// because the Android side only sends a reply when pan/rotation handling is + /// enabled, so awaiting would hang when gestures are disabled. On Android the + /// transform is applied only when `handlePans` or `handleRotation` was enabled + /// in [ARSessionManager.onInitialize]. + void updateNode(ARNode node) { + _channel.invokeMethod('transformationChanged', { 'name': node.name, 'transformation': MatrixValueNotifierConverter().toJson(node.transformNotifier), diff --git a/test/ar_flutter_plugin_test.dart b/test/ar_flutter_plugin_test.dart index b4d56e29..8b8d8e7a 100644 --- a/test/ar_flutter_plugin_test.dart +++ b/test/ar_flutter_plugin_test.dart @@ -62,7 +62,10 @@ void main() { final node = ARNode( type: NodeType.localGLTF2, uri: 'model.gltf', name: 'myNode'); - await objectManager.updateNode(node); + // updateNode is fire-and-forget; flush the event queue so the mock handler + // records the dispatched platform call before asserting. + objectManager.updateNode(node); + await pumpEventQueue(); expect(calls.single.method, 'transformationChanged'); final args = calls.single.arguments as Map; From 01450b36df05a7651d3a3de7856f299ef60089d0 Mon Sep 17 00:00:00 2001 From: Bright Etornam Sunu Date: Wed, 8 Jul 2026 19:48:27 +0000 Subject: [PATCH 3/3] Address review: const converter + null-aware handler calls - const MatrixValueNotifierConverter() to avoid a runtime allocation - onPlaneOrPointTap/onPlaneDetected: use ?.call(...) instead of ! on mutable fields Per gemini-code-assist review on PR #6. --- lib/managers/ar_object_manager.dart | 2 +- lib/managers/ar_session_manager.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/managers/ar_object_manager.dart b/lib/managers/ar_object_manager.dart index b7608a4a..789449b9 100644 --- a/lib/managers/ar_object_manager.dart +++ b/lib/managers/ar_object_manager.dart @@ -160,7 +160,7 @@ class ARObjectManager { _channel.invokeMethod('transformationChanged', { 'name': node.name, 'transformation': - MatrixValueNotifierConverter().toJson(node.transformNotifier), + const MatrixValueNotifierConverter().toJson(node.transformNotifier), }); } } diff --git a/lib/managers/ar_session_manager.dart b/lib/managers/ar_session_manager.dart index ceae7ae0..74541663 100644 --- a/lib/managers/ar_session_manager.dart +++ b/lib/managers/ar_session_manager.dart @@ -158,13 +158,13 @@ class ARSessionManager { final hitTestResults = serializedHitTestResults.map((e) { return ARHitTestResult.fromJson(e); }).toList(); - onPlaneOrPointTap!(hitTestResults); + onPlaneOrPointTap?.call(hitTestResults); } break; case 'onPlaneDetected': if (onPlaneDetected != null) { final planeCountResult = call.arguments as int; - onPlaneDetected!(planeCountResult); + onPlaneDetected?.call(planeCountResult); } break; case 'dispose':