From 4b0299abf6929ef630171a108391000ad0cc404a Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Wed, 19 Nov 2025 10:45:05 +0200 Subject: [PATCH 1/3] feat: add interface for new navigation session detection --- .../GoogleMapsNavigationSessionManager.kt | 11 +++ .../maps/flutter/navigation/messages.g.kt | 27 ++++-- .../integration_test/t03_navigation_test.dart | 90 ++++++++++++++----- example/lib/pages/navigation.dart | 43 +++++++++ .../GoogleMapsNavigationSessionManager.swift | 18 ++++ .../messages.g.swift | 39 +++++--- lib/src/google_navigation_flutter.dart | 3 + lib/src/method_channel/messages.g.dart | 35 ++++++-- lib/src/method_channel/session_api.dart | 15 ++++ .../google_navigation_flutter_navigator.dart | 35 ++++++++ pigeons/messages.dart | 11 ++- test/messages_test.g.dart | 7 +- 12 files changed, 276 insertions(+), 58 deletions(-) diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionManager.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionManager.kt index 7c76f4ce..e084e901 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionManager.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionManager.kt @@ -70,6 +70,7 @@ constructor( RoadSnappedLocationProvider.GpsAvailabilityEnhancedLocationListener? = null private var speedingListener: SpeedingListener? = null + private var navigationSessionListener: Navigator.NavigationSessionListener? = null private var weakActivity: WeakReference? = null private var navInfoObserver: Observer? = null private var weakLifecycleOwner: WeakReference? = null @@ -315,6 +316,10 @@ constructor( navigator.setSpeedingListener(null) speedingListener = null } + if (navigationSessionListener != null) { + navigator.removeNavigationSessionListener(navigationSessionListener) + navigationSessionListener = null + } } if (roadSnappedLocationListener != null) { disableRoadSnappedLocationUpdates() @@ -391,6 +396,12 @@ constructor( } navigator.setSpeedingListener(speedingListener) } + + if (navigationSessionListener == null) { + navigationSessionListener = + Navigator.NavigationSessionListener { navigationSessionEventApi.onNewNavigationSession {} } + navigator.addNavigationSessionListener(navigationSessionListener) + } } /** diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/messages.g.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/messages.g.kt index 90cd57c8..0de11556 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/messages.g.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/messages.g.kt @@ -5693,7 +5693,6 @@ class ViewEventApi( /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface NavigationSessionApi { - /** General. */ fun createNavigationSession( abnormalTerminationReportingEnabled: Boolean, behavior: TaskRemovedBehaviorDto, @@ -5717,7 +5716,6 @@ interface NavigationSessionApi { fun getNavSDKVersion(): String - /** Navigation. */ fun isGuidanceRunning(): Boolean fun startGuidance() @@ -5742,7 +5740,6 @@ interface NavigationSessionApi { fun getCurrentRouteSegment(): RouteSegmentDto? - /** Simulation */ fun setUserLocation(location: LatLngDto) fun removeUserLocation() @@ -5773,15 +5770,13 @@ interface NavigationSessionApi { fun resumeSimulation() - /** Simulation (iOS only) */ + /** iOS-only method. */ fun allowBackgroundLocationUpdates(allow: Boolean) - /** Road snapped location updates. */ fun enableRoadSnappedLocationUpdates() fun disableRoadSnappedLocationUpdates() - /** Enable Turn-by-Turn navigation events. */ fun enableTurnByTurnNavigationEvents(numNextStepsToPreview: Long?) fun disableTurnByTurnNavigationEvents() @@ -6810,6 +6805,26 @@ class NavigationSessionEventApi( } } } + + /** Navigation session event. Called when a new navigation session starts with active guidance. */ + fun onNewNavigationSession(callback: (Result) -> Unit) { + val separatedMessageChannelSuffix = + if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = + "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionEventApi.onNewNavigationSession$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(MessagesPigeonUtils.createConnectionError(channelName))) + } + } + } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ diff --git a/example/integration_test/t03_navigation_test.dart b/example/integration_test/t03_navigation_test.dart index 9dd71858..8d8fd7ca 100644 --- a/example/integration_test/t03_navigation_test.dart +++ b/example/integration_test/t03_navigation_test.dart @@ -59,22 +59,12 @@ void main() { PatrolIntegrationTester $, ) async { final Completer hasArrived = Completer(); + final Completer newSessionFired = Completer(); /// Set up navigation view and controller. final GoogleNavigationViewController viewController = await startNavigationWithoutDestination($); - /// Set audio guidance settings. - /// Cannot be verified, because native SDK lacks getter methods, - /// but exercise the API for basic sanity testing - final NavigationAudioGuidanceSettings settings = - NavigationAudioGuidanceSettings( - isBluetoothAudioEnabled: true, - isVibrationEnabled: true, - guidanceType: NavigationAudioGuidanceType.alertsAndGuidance, - ); - await GoogleMapsNavigator.setAudioGuidance(settings); - /// Specify tolerance and navigation end coordinates. const double tolerance = 0.001; const double endLat = 68.59451829688189, endLng = 23.512277951523007; @@ -86,8 +76,28 @@ void main() { await GoogleMapsNavigator.stopGuidance(); } + /// Set up listener for new navigation session event. + Future onNewNavigationSession() async { + newSessionFired.complete(); + + /// Sets audio guidance settings for the current navigation session. + /// Cannot be verified, because native SDK lacks getter methods, + /// but exercise the API for basic sanity testing. + await GoogleMapsNavigator.setAudioGuidance( + NavigationAudioGuidanceSettings( + isBluetoothAudioEnabled: true, + isVibrationEnabled: true, + guidanceType: NavigationAudioGuidanceType.alertsAndGuidance, + ), + ); + } + final StreamSubscription onArrivalSubscription = GoogleMapsNavigator.setOnArrivalListener(onArrivalEvent); + final StreamSubscription onNewNavigationSessionSubscription = + GoogleMapsNavigator.setOnNewNavigationSessionListener( + onNewNavigationSession, + ); /// Simulate location and test it. await setSimulatedUserLocationWithCheck( @@ -143,11 +153,24 @@ void main() { await GoogleMapsNavigator.simulator.simulateLocationsAlongExistingRoute(); expect(await GoogleMapsNavigator.isGuidanceRunning(), true); + + /// Wait for new navigation session event. + await newSessionFired.future.timeout( + const Duration(seconds: 30), + onTimeout: + () => + throw TimeoutException( + 'New navigation session event was not fired', + ), + ); + expect(newSessionFired.isCompleted, true); + await hasArrived.future; expect(await GoogleMapsNavigator.isGuidanceRunning(), false); // Cancel subscriptions before cleanup await onArrivalSubscription.cancel(); + await onNewNavigationSessionSubscription.cancel(); await roadSnappedSubscription.cancel(); await GoogleMapsNavigator.cleanup(); }); @@ -156,6 +179,7 @@ void main() { 'Test navigating to multiple destinations', (PatrolIntegrationTester $) async { final Completer navigationFinished = Completer(); + final Completer newSessionFired = Completer(); int arrivalEventCount = 0; List waypoints = []; @@ -163,17 +187,6 @@ void main() { final GoogleNavigationViewController viewController = await startNavigationWithoutDestination($); - /// Set audio guidance settings. - /// Cannot be verified, because native SDK lacks getter methods, - /// but exercise the API for basic sanity testing - final NavigationAudioGuidanceSettings settings = - NavigationAudioGuidanceSettings( - isBluetoothAudioEnabled: false, - isVibrationEnabled: false, - guidanceType: NavigationAudioGuidanceType.alertsOnly, - ); - await GoogleMapsNavigator.setAudioGuidance(settings); - /// Specify tolerance and navigation destination coordinates. const double tolerance = 0.001; const double midLat = 68.59781164189049, @@ -234,8 +247,28 @@ void main() { } } + /// Set up listener for new navigation session event. + Future onNewNavigationSession() async { + newSessionFired.complete(); + + /// Sets audio guidance settings for the current navigation session. + /// Cannot be verified, because native SDK lacks getter methods, + /// but exercise the API for basic sanity testing. + await GoogleMapsNavigator.setAudioGuidance( + NavigationAudioGuidanceSettings( + isBluetoothAudioEnabled: true, + isVibrationEnabled: true, + guidanceType: NavigationAudioGuidanceType.alertsAndGuidance, + ), + ); + } + final StreamSubscription onArrivalSubscription = GoogleMapsNavigator.setOnArrivalListener(onArrivalEvent); + final StreamSubscription onNewNavigationSessionSubscription = + GoogleMapsNavigator.setOnNewNavigationSessionListener( + onNewNavigationSession, + ); /// Simulate location and test it. await setSimulatedUserLocationWithCheck( @@ -317,11 +350,24 @@ void main() { ); expect(await GoogleMapsNavigator.isGuidanceRunning(), true); + + /// Wait for new navigation session event. + await newSessionFired.future.timeout( + const Duration(seconds: 30), + onTimeout: + () => + throw TimeoutException( + 'New navigation session event was not fired', + ), + ); + expect(newSessionFired.isCompleted, true); + await navigationFinished.future; expect(await GoogleMapsNavigator.isGuidanceRunning(), false); // Cancel subscriptions before cleanup await onArrivalSubscription.cancel(); + await onNewNavigationSessionSubscription.cancel(); await roadSnappedSubscription.cancel(); await GoogleMapsNavigator.cleanup(); }, diff --git a/example/lib/pages/navigation.dart b/example/lib/pages/navigation.dart index 7dee8189..16fe3bd9 100644 --- a/example/lib/pages/navigation.dart +++ b/example/lib/pages/navigation.dart @@ -98,6 +98,7 @@ class _NavigationPageState extends ExamplePageState { int _onRecenterButtonClickedEventCallCount = 0; int _onRemainingTimeOrDistanceChangedEventCallCount = 0; int _onNavigationUIEnabledChangedEventCallCount = 0; + int _onNewNavigationSessionEventCallCount = 0; bool _navigationHeaderEnabled = true; bool _navigationFooterEnabled = true; @@ -147,6 +148,7 @@ class _NavigationPageState extends ExamplePageState { _roadSnappedLocationUpdatedSubscription; StreamSubscription? _roadSnappedRawLocationUpdatedSubscription; + StreamSubscription? _newNavigationSessionSubscription; int _nextWaypointIndex = 0; @@ -379,6 +381,11 @@ class _NavigationPageState extends ExamplePageState { await GoogleMapsNavigator.setRoadSnappedRawLocationUpdatedListener( _onRoadSnappedRawLocationUpdatedEvent, ); + + _newNavigationSessionSubscription = + GoogleMapsNavigator.setOnNewNavigationSessionListener( + _onNewNavigationSessionEvent, + ); } void _clearListeners() { @@ -408,6 +415,24 @@ class _NavigationPageState extends ExamplePageState { _roadSnappedRawLocationUpdatedSubscription?.cancel(); _roadSnappedRawLocationUpdatedSubscription = null; + + _newNavigationSessionSubscription?.cancel(); + _newNavigationSessionSubscription = null; + } + + void _onNewNavigationSessionEvent() { + if (!mounted) { + return; + } + + setState(() { + _onNewNavigationSessionEventCallCount += 1; + }); + + showMessage('New navigation session started'); + + // Set audio guidance settings for the new navigation session. + unawaited(_setAudioGuidance()); } void _onRoadSnappedLocationUpdatedEvent( @@ -517,6 +542,16 @@ class _NavigationPageState extends ExamplePageState { await _getInitialViewStates(); } + Future _setAudioGuidance() async { + await GoogleMapsNavigator.setAudioGuidance( + NavigationAudioGuidanceSettings( + isBluetoothAudioEnabled: true, + isVibrationEnabled: true, + guidanceType: NavigationAudioGuidanceType.alertsAndGuidance, + ), + ); + } + Future _getInitialViewStates() async { assert(_navigationViewController != null); if (_navigationViewController != null) { @@ -1445,6 +1480,14 @@ class _NavigationPageState extends ExamplePageState { ), ), ), + Card( + child: ListTile( + title: const Text('New navigation session event call count'), + trailing: Text( + _onNewNavigationSessionEventCallCount.toString(), + ), + ), + ), ], ), ); diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift index 9dbc7d97..29e72c93 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift @@ -64,6 +64,8 @@ class GoogleMapsNavigationSessionManager: NSObject { private var _numTurnByTurnNextStepsToPreview = Int64.max + private var _isNewNavigationSessionDetected = false + func getNavigator() throws -> GMSNavigator { guard let _session else { throw GoogleMapsNavigationSessionManagerError.sessionNotInitialized } guard let navigator = _session.navigator @@ -221,6 +223,7 @@ class GoogleMapsNavigationSessionManager: NSObject { func stopGuidance() throws { try getNavigator().isGuidanceActive = false + _isNewNavigationSessionDetected = false } func isGuidanceRunning() throws -> Bool { @@ -247,6 +250,11 @@ class GoogleMapsNavigationSessionManager: NSObject { completion: @escaping (Result) -> Void ) { do { + // Reset session detection state to allow onNewNavigationSession to fire again + // This mimics Android's behavior where the event fires each time setDestinations + // is called while guidance is running + _isNewNavigationSessionDetected = false + // If the session has view attached, enable given display options. handleDisplayOptionsIfNeeded(options: destinations.displayOptions) @@ -294,6 +302,7 @@ class GoogleMapsNavigationSessionManager: NSObject { func clearDestinations() throws { try getNavigator().clearDestinations() + _isNewNavigationSessionDetected = false } func continueToNextDestination() throws -> NavigationWaypointDto? { @@ -589,6 +598,15 @@ extension GoogleMapsNavigationSessionManager: GMSNavigatorListener { _ navigator: GMSNavigator, didUpdate navInfo: GMSNavigationNavInfo ) { + // Detect new navigation session start + // This callback only fires when guidance is actively running, making it the ideal place + // to detect session starts and match Android's behavior where NavigationSessionListener + // fires when guidance begins + if !_isNewNavigationSessionDetected { + _isNewNavigationSessionDetected = true + _navigationSessionEventApi?.onNewNavigationSession(completion: { _ in }) + } + if _sendTurnByTurnNavigationEvents { _navigationSessionEventApi?.onNavInfo( navInfo: Convert.convertNavInfo( diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/messages.g.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/messages.g.swift index d82bb8ad..de9a469c 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/messages.g.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/messages.g.swift @@ -4862,7 +4862,6 @@ class ViewEventApi: ViewEventApiProtocol { } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NavigationSessionApi { - /// General. func createNavigationSession( abnormalTerminationReportingEnabled: Bool, behavior: TaskRemovedBehaviorDto, completion: @escaping (Result) -> Void) @@ -4874,7 +4873,6 @@ protocol NavigationSessionApi { func areTermsAccepted() throws -> Bool func resetTermsAccepted() throws func getNavSDKVersion() throws -> String - /// Navigation. func isGuidanceRunning() throws -> Bool func startGuidance() throws func stopGuidance() throws @@ -4888,7 +4886,6 @@ protocol NavigationSessionApi { func getRouteSegments() throws -> [RouteSegmentDto] func getTraveledRoute() throws -> [LatLngDto] func getCurrentRouteSegment() throws -> RouteSegmentDto? - /// Simulation func setUserLocation(location: LatLngDto) throws func removeUserLocation() throws func simulateLocationsAlongExistingRoute() throws @@ -4905,12 +4902,10 @@ protocol NavigationSessionApi { completion: @escaping (Result) -> Void) func pauseSimulation() throws func resumeSimulation() throws - /// Simulation (iOS only) + /// iOS-only method. func allowBackgroundLocationUpdates(allow: Bool) throws - /// Road snapped location updates. func enableRoadSnappedLocationUpdates() throws func disableRoadSnappedLocationUpdates() throws - /// Enable Turn-by-Turn navigation events. func enableTurnByTurnNavigationEvents(numNextStepsToPreview: Int64?) throws func disableTurnByTurnNavigationEvents() throws func registerRemainingTimeOrDistanceChangedListener( @@ -4926,7 +4921,6 @@ class NavigationSessionApiSetup { messageChannelSuffix: String = "" ) { let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - /// General. let createNavigationSessionChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.createNavigationSession\(channelSuffix)", @@ -5058,7 +5052,6 @@ class NavigationSessionApiSetup { } else { getNavSDKVersionChannel.setMessageHandler(nil) } - /// Navigation. let isGuidanceRunningChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.isGuidanceRunning\(channelSuffix)", @@ -5259,7 +5252,6 @@ class NavigationSessionApiSetup { } else { getCurrentRouteSegmentChannel.setMessageHandler(nil) } - /// Simulation let setUserLocationChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.setUserLocation\(channelSuffix)", @@ -5430,7 +5422,7 @@ class NavigationSessionApiSetup { } else { resumeSimulationChannel.setMessageHandler(nil) } - /// Simulation (iOS only) + /// iOS-only method. let allowBackgroundLocationUpdatesChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.allowBackgroundLocationUpdates\(channelSuffix)", @@ -5449,7 +5441,6 @@ class NavigationSessionApiSetup { } else { allowBackgroundLocationUpdatesChannel.setMessageHandler(nil) } - /// Road snapped location updates. let enableRoadSnappedLocationUpdatesChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.enableRoadSnappedLocationUpdates\(channelSuffix)", @@ -5482,7 +5473,6 @@ class NavigationSessionApiSetup { } else { disableRoadSnappedLocationUpdatesChannel.setMessageHandler(nil) } - /// Enable Turn-by-Turn navigation events. let enableTurnByTurnNavigationEventsChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.enableTurnByTurnNavigationEvents\(channelSuffix)", @@ -5565,6 +5555,9 @@ protocol NavigationSessionEventApiProtocol { /// Turn-by-Turn navigation events. func onNavInfo( navInfo navInfoArg: NavInfoDto, completion: @escaping (Result) -> Void) + /// Navigation session event. Called when a new navigation + /// session starts with active guidance. + func onNewNavigationSession(completion: @escaping (Result) -> Void) } class NavigationSessionEventApi: NavigationSessionEventApiProtocol { private let binaryMessenger: FlutterBinaryMessenger @@ -5796,6 +5789,28 @@ class NavigationSessionEventApi: NavigationSessionEventApiProtocol { } } } + /// Navigation session event. Called when a new navigation + /// session starts with active guidance. + func onNewNavigationSession(completion: @escaping (Result) -> Void) { + let channelName: String = + "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionEventApi.onNewNavigationSession\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel( + name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage(nil) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol AutoMapViewApi { diff --git a/lib/src/google_navigation_flutter.dart b/lib/src/google_navigation_flutter.dart index df1dc9a5..dc92d3cf 100644 --- a/lib/src/google_navigation_flutter.dart +++ b/lib/src/google_navigation_flutter.dart @@ -68,6 +68,9 @@ typedef OnRemainingTimeOrDistanceChangedEventCallback = /// Called on navigation info event. typedef OnNavInfoEventCallback = void Function(NavInfoEvent onNavInfo); +/// Called on new navigation session event. +typedef OnNewNavigationSessionCallback = void Function(); + /// Called during marker click event. typedef OnMarkerClicked = void Function(String markerId); diff --git a/lib/src/method_channel/messages.g.dart b/lib/src/method_channel/messages.g.dart index 9ad8f4e9..fcb40666 100644 --- a/lib/src/method_channel/messages.g.dart +++ b/lib/src/method_channel/messages.g.dart @@ -6632,7 +6632,6 @@ class NavigationSessionApi { final String pigeonVar_messageChannelSuffix; - /// General. Future createNavigationSession( bool abnormalTerminationReportingEnabled, TaskRemovedBehaviorDto behavior, @@ -6841,7 +6840,6 @@ class NavigationSessionApi { } } - /// Navigation. Future isGuidanceRunning() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.isGuidanceRunning$pigeonVar_messageChannelSuffix'; @@ -7176,7 +7174,6 @@ class NavigationSessionApi { } } - /// Simulation Future setUserLocation(LatLngDto location) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.setUserLocation$pigeonVar_messageChannelSuffix'; @@ -7439,7 +7436,7 @@ class NavigationSessionApi { } } - /// Simulation (iOS only) + /// iOS-only method. Future allowBackgroundLocationUpdates(bool allow) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.allowBackgroundLocationUpdates$pigeonVar_messageChannelSuffix'; @@ -7467,7 +7464,6 @@ class NavigationSessionApi { } } - /// Road snapped location updates. Future enableRoadSnappedLocationUpdates() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.enableRoadSnappedLocationUpdates$pigeonVar_messageChannelSuffix'; @@ -7518,7 +7514,6 @@ class NavigationSessionApi { } } - /// Enable Turn-by-Turn navigation events. Future enableTurnByTurnNavigationEvents( int? numNextStepsToPreview, ) async { @@ -7637,6 +7632,10 @@ abstract class NavigationSessionEventApi { /// Turn-by-Turn navigation events. void onNavInfo(NavInfoDto navInfo); + /// Navigation session event. Called when a new navigation + /// session starts with active guidance. + void onNewNavigationSession(); + static void setUp( NavigationSessionEventApi? api, { BinaryMessenger? binaryMessenger, @@ -7964,6 +7963,30 @@ abstract class NavigationSessionEventApi { }); } } + { + final BasicMessageChannel + pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.google_navigation_flutter.NavigationSessionEventApi.onNewNavigationSession$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + api.onNewNavigationSession(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } } } diff --git a/lib/src/method_channel/session_api.dart b/lib/src/method_channel/session_api.dart index bff7a621..c74093c7 100644 --- a/lib/src/method_channel/session_api.dart +++ b/lib/src/method_channel/session_api.dart @@ -668,6 +668,13 @@ class NavigationSessionAPIImpl { Stream getNavInfoStream() { return _sessionEventStreamController.stream.whereType(); } + + /// Get new navigation session event stream from the navigation session. + Stream getNewNavigationSessionEventStream() { + return _sessionEventStreamController.stream + .whereType<_NewNavigationSessionEvent>() + .map((_NewNavigationSessionEvent event) => ()); + } } /// Implementation for navigation session event API event handling. @@ -748,6 +755,11 @@ class NavigationSessionEventApiImpl implements NavigationSessionEventApi { NavInfoEvent(navInfo: navInfo.toNavInfo()), ); } + + @override + void onNewNavigationSession() { + sessionEventStreamController.add(_NewNavigationSessionEvent()); + } } /// Event wrapper for a route update events. @@ -758,3 +770,6 @@ class _ReroutingEvent {} /// Event wrapper for a traffic updated events. class _TrafficUpdatedEvent {} + +/// Event wrapper for a new navigation session event. +class _NewNavigationSessionEvent {} diff --git a/lib/src/navigator/google_navigation_flutter_navigator.dart b/lib/src/navigator/google_navigation_flutter_navigator.dart index 59428c4b..dde17263 100644 --- a/lib/src/navigator/google_navigation_flutter_navigator.dart +++ b/lib/src/navigator/google_navigation_flutter_navigator.dart @@ -126,6 +126,41 @@ class GoogleMapsNavigator { .listen(listener); } + /// Sets the event channel listener for new navigation session events. + /// + /// This event is triggered when a new navigation session starts with guidance active. + /// On both Android and iOS, this fires when: + /// - A route has been set via [setDestinations] + /// - Guidance is started with [startGuidance] + /// + /// On Android, this wraps Navigator.NavigationSessionListener.onNewNavigationSession. + /// On iOS, this is detected internally when a route is set and guidance becomes active. + /// + /// Returns a [StreamSubscription] for new navigation session events. + /// This subscription must be canceled using `cancel()` when it is no longer + /// needed to stop receiving events and allow the stream to perform necessary + /// cleanup, such as releasing resources or shutting down event sources. The + /// cleanup is asynchronous, and the `cancel()` method returns a Future that + /// completes once the cleanup is done. + /// + /// Example usage: + /// ```dart + /// final subscription = setOnNewNavigationSessionListener(() { + /// print('New navigation session started'); + /// }); + /// // When done with the subscription + /// await subscription.cancel(); + /// ``` + static StreamSubscription setOnNewNavigationSessionListener( + OnNewNavigationSessionCallback listener, + ) { + return GoogleMapsNavigationPlatform.instance.navigationSessionAPI + .getNewNavigationSessionEventStream() + .listen((void event) { + listener.call(); + }); + } + /// Sets the event channel listener for the rerouting events. (Android only) /// /// Returns a [StreamSubscription] for rerouting events. diff --git a/pigeons/messages.dart b/pigeons/messages.dart index 9de0342b..e2da527c 100644 --- a/pigeons/messages.dart +++ b/pigeons/messages.dart @@ -1217,7 +1217,6 @@ enum TaskRemovedBehaviorDto { @HostApi(dartHostTestHandler: 'TestNavigationSessionApi') abstract class NavigationSessionApi { - /// General. @async void createNavigationSession( bool abnormalTerminationReportingEnabled, @@ -1235,7 +1234,6 @@ abstract class NavigationSessionApi { void resetTermsAccepted(); String getNavSDKVersion(); - /// Navigation. bool isGuidanceRunning(); void startGuidance(); void stopGuidance(); @@ -1250,7 +1248,6 @@ abstract class NavigationSessionApi { List getTraveledRoute(); RouteSegmentDto? getCurrentRouteSegment(); - /// Simulation void setUserLocation(LatLngDto location); void removeUserLocation(); void simulateLocationsAlongExistingRoute(); @@ -1275,14 +1272,12 @@ abstract class NavigationSessionApi { void pauseSimulation(); void resumeSimulation(); - /// Simulation (iOS only) + /// iOS-only method. void allowBackgroundLocationUpdates(bool allow); - /// Road snapped location updates. void enableRoadSnappedLocationUpdates(); void disableRoadSnappedLocationUpdates(); - /// Enable Turn-by-Turn navigation events. void enableTurnByTurnNavigationEvents(int? numNextStepsToPreview); void disableTurnByTurnNavigationEvents(); @@ -1315,6 +1310,10 @@ abstract class NavigationSessionEventApi { /// Turn-by-Turn navigation events. void onNavInfo(NavInfoDto navInfo); + + /// Navigation session event. Called when a new navigation + /// session starts with active guidance. + void onNewNavigationSession(); } @HostApi() diff --git a/test/messages_test.g.dart b/test/messages_test.g.dart index f94c7885..cb992607 100644 --- a/test/messages_test.g.dart +++ b/test/messages_test.g.dart @@ -4967,7 +4967,6 @@ abstract class TestNavigationSessionApi { TestDefaultBinaryMessengerBinding.instance; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - /// General. Future createNavigationSession( bool abnormalTerminationReportingEnabled, TaskRemovedBehaviorDto behavior, @@ -4989,7 +4988,6 @@ abstract class TestNavigationSessionApi { String getNavSDKVersion(); - /// Navigation. bool isGuidanceRunning(); void startGuidance(); @@ -5014,7 +5012,6 @@ abstract class TestNavigationSessionApi { RouteSegmentDto? getCurrentRouteSegment(); - /// Simulation void setUserLocation(LatLngDto location); void removeUserLocation(); @@ -5045,15 +5042,13 @@ abstract class TestNavigationSessionApi { void resumeSimulation(); - /// Simulation (iOS only) + /// iOS-only method. void allowBackgroundLocationUpdates(bool allow); - /// Road snapped location updates. void enableRoadSnappedLocationUpdates(); void disableRoadSnappedLocationUpdates(); - /// Enable Turn-by-Turn navigation events. void enableTurnByTurnNavigationEvents(int? numNextStepsToPreview); void disableTurnByTurnNavigationEvents(); From 60c02af85b6eb6956f7fcd1f1e70d19553ee162d Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Wed, 19 Nov 2025 12:25:45 +0200 Subject: [PATCH 2/3] test: fix test for multiple destinations navigation --- .../integration_test/t03_navigation_test.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/example/integration_test/t03_navigation_test.dart b/example/integration_test/t03_navigation_test.dart index 8d8fd7ca..c611485a 100644 --- a/example/integration_test/t03_navigation_test.dart +++ b/example/integration_test/t03_navigation_test.dart @@ -179,7 +179,7 @@ void main() { 'Test navigating to multiple destinations', (PatrolIntegrationTester $) async { final Completer navigationFinished = Completer(); - final Completer newSessionFired = Completer(); + Completer newSessionFired = Completer(); int arrivalEventCount = 0; List waypoints = []; @@ -210,6 +210,9 @@ void main() { SimulationOptions(speedMultiplier: 5), ); } else { + // Reset the completer to test that new session event fires again + newSessionFired = Completer(); + // Find and remove the waypoint that matches the arrived waypoint int waypointIndex = -1; for (int i = 0; i < waypoints.length; i++) { @@ -233,6 +236,17 @@ void main() { ), ); await GoogleMapsNavigator.setDestinations(updatedDestinations); + + // Wait for new session event after updating destinations + await newSessionFired.future.timeout( + const Duration(seconds: 10), + onTimeout: + () => + throw TimeoutException( + 'New navigation session event was not fired after updating destinations', + ), + ); + await GoogleMapsNavigator.simulator .simulateLocationsAlongExistingRouteWithOptions( SimulationOptions(speedMultiplier: 5), From ba2cfa73fbc16da68e31b1237c999976a8123db3 Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Wed, 19 Nov 2025 13:47:42 +0200 Subject: [PATCH 3/3] fix: onNewNavigationSession event firing for continueToNextDestination --- .../integration_test/t03_navigation_test.dart | 26 +++++++++---------- .../GoogleMapsNavigationSessionManager.swift | 4 +++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/example/integration_test/t03_navigation_test.dart b/example/integration_test/t03_navigation_test.dart index c611485a..f4e54b5b 100644 --- a/example/integration_test/t03_navigation_test.dart +++ b/example/integration_test/t03_navigation_test.dart @@ -197,6 +197,9 @@ void main() { Future onArrivalEvent(OnArrivalEvent msg) async { arrivalEventCount += 1; if (arrivalEventCount < 2) { + // Reset the completer to test that new session event fires again + newSessionFired = Completer(); + if (multipleDestinationsVariants.currentValue == 'continueToNextDestination') { // Note: continueToNextDestination is deprecated. @@ -210,9 +213,6 @@ void main() { SimulationOptions(speedMultiplier: 5), ); } else { - // Reset the completer to test that new session event fires again - newSessionFired = Completer(); - // Find and remove the waypoint that matches the arrived waypoint int waypointIndex = -1; for (int i = 0; i < waypoints.length; i++) { @@ -237,22 +237,22 @@ void main() { ); await GoogleMapsNavigator.setDestinations(updatedDestinations); - // Wait for new session event after updating destinations - await newSessionFired.future.timeout( - const Duration(seconds: 10), - onTimeout: - () => - throw TimeoutException( - 'New navigation session event was not fired after updating destinations', - ), - ); - await GoogleMapsNavigator.simulator .simulateLocationsAlongExistingRouteWithOptions( SimulationOptions(speedMultiplier: 5), ); } } + + // Wait for new session event after updating destinations + await newSessionFired.future.timeout( + const Duration(seconds: 10), + onTimeout: + () => + throw TimeoutException( + 'New navigation session event was not fired after updating destinations', + ), + ); } else { $.log('Got second arrival event, stopping guidance'); // Stop guidance after the last destination diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift index 29e72c93..41134c1f 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift @@ -306,6 +306,10 @@ class GoogleMapsNavigationSessionManager: NSObject { } func continueToNextDestination() throws -> NavigationWaypointDto? { + // Reset session detection state to allow onNewNavigationSession to fire again + // This mimics Android's behavior where the event fires when continuing to next destination + _isNewNavigationSessionDetected = false + guard let nextWaypoint = try getNavigator().continueToNextDestination() else { return nil } return Convert.convertNavigationWayPoint(nextWaypoint) }