From 0b0ce749dfa665d8c36c672df20ba76619ee02c3 Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Wed, 5 Nov 2025 11:16:06 +0200 Subject: [PATCH] feat: add foreground service example for android --- .../navigation/AndroidAutoBaseScreen.kt | 4 +- .../GoogleMapsNavigationInspectorHandler.kt | 8 +- .../navigation/GoogleMapsNavigationPlugin.kt | 15 +- .../GoogleMapsNavigationSessionManager.kt | 186 ++--- ...ogleMapsNavigationSessionMessageHandler.kt | 36 +- .../navigation/GoogleMapsNavigationView.kt | 7 +- .../navigation/GoogleMapsNavigatorHolder.kt | 189 ++++- .../navigation/GoogleMapsViewFactory.kt | 4 +- .../maps/flutter/navigation/messages.g.kt | 8 +- .../android/app/src/main/AndroidManifest.xml | 15 +- .../integration_test/t09_isolates_test.dart | 441 +++++++++++ example/lib/main.dart | 1 + example/lib/pages/background_navigation.dart | 714 ++++++++++++++++++ example/lib/pages/pages.dart | 1 + example/pubspec.yaml | 2 + .../GoogleMapsNavigationSessionManager.swift | 16 +- ...eMapsNavigationSessionMessageHandler.swift | 4 +- .../messages.g.swift | 8 +- lib/src/method_channel/messages.g.dart | 6 +- lib/src/method_channel/session_api.dart | 12 +- .../google_navigation_flutter_navigator.dart | 29 +- pigeons/messages.dart | 2 +- .../google_navigation_flutter_test.mocks.dart | 4 +- test/messages_test.g.dart | 14 +- 24 files changed, 1510 insertions(+), 216 deletions(-) create mode 100644 example/integration_test/t09_isolates_test.dart create mode 100644 example/lib/pages/background_navigation.dart diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/AndroidAutoBaseScreen.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/AndroidAutoBaseScreen.kt index 0e54fd48..3892c324 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/AndroidAutoBaseScreen.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/AndroidAutoBaseScreen.kt @@ -55,7 +55,9 @@ open class AndroidAutoBaseScreen(carContext: CarContext) : private fun initializeNavigationListener() { GoogleMapsNavigationSessionManager.navigationReadyListener = this - mIsNavigationReady = GoogleMapsNavigatorHolder.getInitializationState() == GoogleNavigatorInitializationState.INITIALIZED + mIsNavigationReady = + GoogleMapsNavigatorHolder.getInitializationState() == + GoogleNavigatorInitializationState.INITIALIZED } private fun initializeSurfaceCallback() { diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationInspectorHandler.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationInspectorHandler.kt index f9b5e19f..974c98c2 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationInspectorHandler.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationInspectorHandler.kt @@ -14,13 +14,13 @@ package com.google.maps.flutter.navigation -class GoogleMapsNavigationInspectorHandler( - private val viewRegistry: GoogleMapsViewRegistry -) : NavigationInspector { +class GoogleMapsNavigationInspectorHandler(private val viewRegistry: GoogleMapsViewRegistry) : + NavigationInspector { override fun isViewAttachedToSession(viewId: Long): Boolean { /// Is session exists, it's automatically attached to any existing view. if (viewRegistry.getNavigationView(viewId.toInt()) != null) { - return GoogleMapsNavigatorHolder.getInitializationState() == GoogleNavigatorInitializationState.INITIALIZED + return GoogleMapsNavigatorHolder.getInitializationState() == + GoogleNavigatorInitializationState.INITIALIZED } return false } diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationPlugin.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationPlugin.kt index 4a83833c..1d1e43ce 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationPlugin.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationPlugin.kt @@ -16,6 +16,7 @@ package com.google.maps.flutter.navigation +import android.app.Application import androidx.lifecycle.Lifecycle import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -40,14 +41,15 @@ class GoogleMapsNavigationPlugin : FlutterPlugin, ActivityAware { private var viewMessageHandler: GoogleMapsViewMessageHandler? = null private var imageRegistryMessageHandler: GoogleMapsImageRegistryMessageHandler? = null private var autoViewMessageHandler: GoogleMapsAutoViewMessageHandler? = null - + // Instance-level session manager instead of singleton internal var sessionManager: GoogleMapsNavigationSessionManager? = null private var lifecycle: Lifecycle? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - // Store first instance of the plugin so that Android Auto will get access to the correct object instances. + // Store first instance of the plugin so that Android Auto will get access to the correct object + // instances. if (instance == null) { instance = this } @@ -68,19 +70,20 @@ class GoogleMapsNavigationPlugin : FlutterPlugin, ActivityAware { AutoMapViewApi.setUp(binding.binaryMessenger, autoViewMessageHandler) autoViewEventApi = AutoViewEventApi(binding.binaryMessenger) - // Setup navigation session manager (instance-level, not singleton) + // Setup navigation session manager + val app = binding.applicationContext as Application val navigationSessionEventApi = NavigationSessionEventApi(binding.binaryMessenger) - sessionManager = GoogleMapsNavigationSessionManager(navigationSessionEventApi) + sessionManager = GoogleMapsNavigationSessionManager(navigationSessionEventApi, app) // Setup platform view factory and its method channel handlers viewEventApi = ViewEventApi(binding.binaryMessenger) val factory = GoogleMapsViewFactory(viewRegistry!!, viewEventApi!!, imageRegistry!!) binding.platformViewRegistry.registerViewFactory("google_navigation_flutter", factory) - + // Setup navigation session message handler with this instance's session manager val sessionMessageHandler = GoogleMapsNavigationSessionMessageHandler(sessionManager!!) NavigationSessionApi.setUp(binding.binaryMessenger, sessionMessageHandler) - + val inspectorHandler = GoogleMapsNavigationInspectorHandler(viewRegistry!!) NavigationInspector.setUp(binding.binaryMessenger, inspectorHandler) } 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 3a48daf0..4d1280d4 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 @@ -17,8 +17,8 @@ package com.google.maps.flutter.navigation import android.app.Activity +import android.app.Application import android.location.Location -import android.util.DisplayMetrics import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer @@ -28,8 +28,6 @@ import com.google.android.libraries.navigation.CustomRoutesOptions import com.google.android.libraries.navigation.DisplayOptions import com.google.android.libraries.navigation.NavigationApi import com.google.android.libraries.navigation.NavigationApi.NavigatorListener -import com.google.android.libraries.navigation.NavigationUpdatesOptions -import com.google.android.libraries.navigation.NavigationUpdatesOptions.GeneratedStepImagesType import com.google.android.libraries.navigation.Navigator import com.google.android.libraries.navigation.Navigator.TaskRemovedBehavior import com.google.android.libraries.navigation.RoadSnappedLocationProvider @@ -44,7 +42,6 @@ import com.google.android.libraries.navigation.TermsAndConditionsUIParams import com.google.android.libraries.navigation.TimeAndDistance import com.google.android.libraries.navigation.Waypoint import com.google.maps.flutter.navigation.Convert.convertTravelModeFromDto -import io.flutter.plugin.common.BinaryMessenger import java.lang.ref.WeakReference interface NavigationReadyListener { @@ -53,8 +50,10 @@ interface NavigationReadyListener { /** This class handles creation of navigation session and other navigation related tasks. */ class GoogleMapsNavigationSessionManager -constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : - DefaultLifecycleObserver { +constructor( + private val navigationSessionEventApi: NavigationSessionEventApi, + private val application: Application, +) : DefaultLifecycleObserver { companion object { var navigationReadyListener: NavigationReadyListener? = null } @@ -72,7 +71,7 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : null private var speedingListener: SpeedingListener? = null private var weakActivity: WeakReference? = null - private var turnByTurnEventsEnabled: Boolean = false + private var navInfoObserver: Observer? = null private var weakLifecycleOwner: WeakReference? = null private var taskRemovedBehavior: @TaskRemovedBehavior Int = 0 @@ -129,7 +128,7 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : callback: (Result) -> Unit, ) { val currentState = GoogleMapsNavigatorHolder.getInitializationState() - + if (currentState == GoogleNavigatorInitializationState.INITIALIZED) { // Navigator is already initialized, just re-register listeners. registerNavigationListeners() @@ -137,25 +136,26 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : callback(Result.success(Unit)) return } - + // Check if initialization is already in progress by another instance if (currentState == GoogleNavigatorInitializationState.INITIALIZING) { // Add this callback to the queue to be called when initialization completes - val queuedListener = object : NavigatorListener { - override fun onNavigatorReady(newNavigator: Navigator) { - registerNavigationListeners() - navigationReadyListener?.onNavigationReady(true) - callback(Result.success(Unit)) - } + val queuedListener = + object : NavigatorListener { + override fun onNavigatorReady(newNavigator: Navigator) { + registerNavigationListeners() + navigationReadyListener?.onNavigationReady(true) + callback(Result.success(Unit)) + } - override fun onError(@NavigationApi.ErrorCode errorCode: Int) { - callback(Result.failure(convertNavigatorErrorToFlutterError(errorCode))) + override fun onError(@NavigationApi.ErrorCode errorCode: Int) { + callback(Result.failure(convertNavigatorErrorToFlutterError(errorCode))) + } } - } GoogleMapsNavigatorHolder.addInitializationCallback(queuedListener) return } - + taskRemovedBehavior = Convert.taskRemovedBehaviorDtoToTaskRemovedBehavior(behavior) // Align API behavior with iOS: @@ -176,48 +176,57 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : NavigationApi.setAbnormalTerminationReportingEnabled(abnormalTerminationReportingEnabled) // Mark initialization as in progress - GoogleMapsNavigatorHolder.setInitializationState(GoogleNavigatorInitializationState.INITIALIZING) + GoogleMapsNavigatorHolder.setInitializationState( + GoogleNavigatorInitializationState.INITIALIZING + ) val listener = object : NavigatorListener { override fun onNavigatorReady(newNavigator: Navigator) { - if (GoogleMapsNavigatorHolder.getInitializationState() != GoogleNavigatorInitializationState.INITIALIZING) { - GoogleMapsNavigatorHolder.setNavigator(null); + if ( + GoogleMapsNavigatorHolder.getInitializationState() != + GoogleNavigatorInitializationState.INITIALIZING + ) { + GoogleMapsNavigatorHolder.setNavigator(null) return } GoogleMapsNavigatorHolder.setNavigator(newNavigator) newNavigator.setTaskRemovedBehavior(taskRemovedBehavior) registerNavigationListeners() navigationReadyListener?.onNavigationReady(true) - + // Notify all queued callbacks val queuedCallbacks = GoogleMapsNavigatorHolder.getAndClearInitializationCallbacks() for (queuedCallback in queuedCallbacks) { queuedCallback.onNavigatorReady(newNavigator) } - + callback(Result.success(Unit)) } override fun onError(@NavigationApi.ErrorCode errorCode: Int) { - GoogleMapsNavigatorHolder.setInitializationState(GoogleNavigatorInitializationState.NOT_INITIALIZED) - + GoogleMapsNavigatorHolder.setInitializationState( + GoogleNavigatorInitializationState.NOT_INITIALIZED + ) + val error = convertNavigatorErrorToFlutterError(errorCode) - + // Notify all queued callbacks about the error val queuedCallbacks = GoogleMapsNavigatorHolder.getAndClearInitializationCallbacks() for (queuedCallback in queuedCallbacks) { queuedCallback.onError(errorCode) } - + callback(Result.failure(error)) } } - NavigationApi.getNavigator(getActivity(), listener) + NavigationApi.getNavigator(application, listener) } - - private fun convertNavigatorErrorToFlutterError(@NavigationApi.ErrorCode errorCode: Int): FlutterError { + + private fun convertNavigatorErrorToFlutterError( + @NavigationApi.ErrorCode errorCode: Int + ): FlutterError { // Keep in sync with GoogleMapsNavigationSessionManager.swift return when (errorCode) { NavigationApi.ErrorCode.NOT_AUTHORIZED -> { @@ -245,10 +254,7 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : ) } else -> { - FlutterError( - "unknownError", - "The session initialization failed with an unknown error.", - ) + FlutterError("unknownError", "The session initialization failed with an unknown error.") } } } @@ -258,34 +264,29 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : return if (roadSnappedLocationProvider != null) { roadSnappedLocationProvider } else { - val application = getActivity().application - if (application != null) { - roadSnappedLocationProvider = NavigationApi.getRoadSnappedLocationProvider(application) - roadSnappedLocationProvider - } else { - throw FlutterError( - "roadSnappedLocationProviderUnavailable", - "Could not get the road snapped location provider, activity not set.", - ) - } + roadSnappedLocationProvider = NavigationApi.getRoadSnappedLocationProvider(application) + roadSnappedLocationProvider } } /** Stops navigation and cleans up internal state of the navigator when it's no longer needed. */ - fun cleanup() { - val navigator = getNavigator() - navigator.stopGuidance() - navigator.clearDestinations() - navigator.simulator.unsetUserLocation() + fun cleanup(resetSession: Boolean = true) { unregisterListeners() - // As unregisterListeners() is removing all listeners, we need to re-register them when - // navigator is re-initialized. This is done in createNavigationSession() method. - GoogleMapsNavigatorHolder.setNavigator(null) - navigationReadyListener?.onNavigationReady(false) + if (resetSession) { + val navigator = getNavigator() + navigator.stopGuidance() + navigator.clearDestinations() + navigator.simulator.unsetUserLocation() + + // As unregisterListeners() is removing all listeners, we need to re-register them when + // navigator is re-initialized. This is done in createNavigationSession() method. + GoogleMapsNavigatorHolder.reset() + navigationReadyListener?.onNavigationReady(false) + } } - private fun unregisterListeners() { + internal fun unregisterListeners() { val navigator = GoogleMapsNavigatorHolder.getNavigator() if (navigator != null) { if (remainingTimeOrDistanceChangedListener != null) { @@ -318,7 +319,7 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : if (roadSnappedLocationListener != null) { disableRoadSnappedLocationUpdates() } - if (turnByTurnEventsEnabled) { + if (navInfoObserver != null) { disableTurnByTurnNavigationEvents() } } @@ -551,7 +552,7 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : * @return true if the terms have been accepted by the user, and false otherwise. */ fun areTermsAccepted(): Boolean { - return NavigationApi.areTermsAccepted(getActivity().application) + return NavigationApi.areTermsAccepted(application) } /** @@ -560,7 +561,7 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : */ fun resetTermsAccepted() { try { - NavigationApi.resetTermsAccepted(getActivity().application) + NavigationApi.resetTermsAccepted(application) } catch (error: IllegalStateException) { throw FlutterError( "termsResetNotAllowed", @@ -684,63 +685,38 @@ constructor(private val navigationSessionEventApi: NavigationSessionEventApi) : @Throws(FlutterError::class) fun enableTurnByTurnNavigationEvents(numNextStepsToPreview: Int) { - val lifeCycleOwner: LifecycleOwner? = weakLifecycleOwner?.get() - if (!turnByTurnEventsEnabled && lifeCycleOwner != null) { - - /// DisplayMetrics is required to be set for turn-by-turn updates. - /// But not used as image generation is disabled. - val displayMetrics = DisplayMetrics() - displayMetrics.density = 2.0f - - // Configure options for navigation updates. - val options = - NavigationUpdatesOptions.builder() - .setNumNextStepsToPreview(numNextStepsToPreview) - .setGeneratedStepImagesType(GeneratedStepImagesType.NONE) - .setDisplayMetrics(displayMetrics) - .build() - - // Attempt to register the service for navigation updates. - val success = - getNavigator() - .registerServiceForNavUpdates( - getActivity().packageName, - GoogleMapsNavigationNavUpdatesService::class.java.name, - options, - ) - - if (success) { - val navInfoObserver: Observer = Observer { navInfo -> - navigationSessionEventApi.onNavInfo(Convert.convertNavInfo(navInfo)) {} - } - GoogleMapsNavigationNavUpdatesService.navInfoLiveData.observe( - lifeCycleOwner, - navInfoObserver, - ) - turnByTurnEventsEnabled = true - } else { + if (navInfoObserver == null) { + // Register the service centrally (if not already registered) + val success = GoogleMapsNavigatorHolder.registerTurnByTurnService( + application, + numNextStepsToPreview + ) + + if (!success) { throw FlutterError( "turnByTurnServiceError", "Error while registering turn-by-turn updates service.", ) } + + // Create observer for this session manager + navInfoObserver = Observer { navInfo -> + navigationSessionEventApi.onNavInfo(Convert.convertNavInfo(navInfo)) {} + } + + // Add observer using observeForever (works without lifecycle owner) + GoogleMapsNavigatorHolder.addNavInfoObserver(navInfoObserver!!) } } @Throws(FlutterError::class) fun disableTurnByTurnNavigationEvents() { - val lifeCycleOwner: LifecycleOwner? = weakLifecycleOwner?.get() - if (turnByTurnEventsEnabled && lifeCycleOwner != null) { - GoogleMapsNavigationNavUpdatesService.navInfoLiveData.removeObservers(lifeCycleOwner) - val success = getNavigator().unregisterServiceForNavUpdates() - if (success) { - turnByTurnEventsEnabled = false - } else { - throw FlutterError( - "turnByTurnServiceError", - "Error while unregistering turn-by-turn updates service.", - ) - } + if (navInfoObserver != null) { + GoogleMapsNavigatorHolder.removeNavInfoObserver(navInfoObserver!!) + navInfoObserver = null + + // Note: Service will only be unregistered when all observers are removed + GoogleMapsNavigatorHolder.unregisterTurnByTurnService() } } diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionMessageHandler.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionMessageHandler.kt index 75946822..16db8d8b 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionMessageHandler.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationSessionMessageHandler.kt @@ -33,11 +33,12 @@ class GoogleMapsNavigationSessionMessageHandler( } override fun isInitialized(): Boolean { - return GoogleMapsNavigatorHolder.getInitializationState() == GoogleNavigatorInitializationState.INITIALIZED + return GoogleMapsNavigatorHolder.getInitializationState() == + GoogleNavigatorInitializationState.INITIALIZED } - override fun cleanup() { - sessionManager.cleanup() + override fun cleanup(resetSession: Boolean) { + sessionManager.cleanup(resetSession) } override fun showTermsAndConditionsDialog( @@ -46,13 +47,12 @@ class GoogleMapsNavigationSessionMessageHandler( shouldOnlyShowDriverAwarenessDisclaimer: Boolean, callback: (Result) -> Unit, ) { - sessionManager - .showTermsAndConditionsDialog( - title, - companyName, - shouldOnlyShowDriverAwarenessDisclaimer, - callback, - ) + sessionManager.showTermsAndConditionsDialog( + title, + companyName, + shouldOnlyShowDriverAwarenessDisclaimer, + callback, + ) } override fun areTermsAccepted(): Boolean { @@ -168,10 +168,9 @@ class GoogleMapsNavigationSessionMessageHandler( } override fun simulateLocationsAlongExistingRouteWithOptions(options: SimulationOptionsDto) { - sessionManager - .simulateLocationsAlongExistingRouteWithOptions( - SimulationOptions().speedMultiplier(options.speedMultiplier.toFloat()) - ) + sessionManager.simulateLocationsAlongExistingRouteWithOptions( + SimulationOptions().speedMultiplier(options.speedMultiplier.toFloat()) + ) } override fun simulateLocationsAlongNewRoute( @@ -267,10 +266,9 @@ class GoogleMapsNavigationSessionMessageHandler( remainingTimeThresholdSeconds: Long, remainingDistanceThresholdMeters: Long, ) { - sessionManager - .registerRemainingTimeOrDistanceChangedListener( - remainingTimeThresholdSeconds, - remainingDistanceThresholdMeters, - ) + sessionManager.registerRemainingTimeOrDistanceChangedListener( + remainingTimeThresholdSeconds, + remainingDistanceThresholdMeters, + ) } } diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationView.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationView.kt index 4e045955..d4ee0ccf 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationView.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigationView.kt @@ -32,7 +32,7 @@ internal constructor( viewId: Int, private val viewRegistry: GoogleMapsViewRegistry, viewEventApi: ViewEventApi, - private val imageRegistry: ImageRegistry + private val imageRegistry: ImageRegistry, ) : PlatformView, GoogleMapsBaseMapView(viewId, mapOptions, viewEventApi, imageRegistry) { private val _navigationView: NavigationView = NavigationView(context, mapOptions.googleMapOptions) @@ -65,8 +65,9 @@ internal constructor( // Initialize navigation view with given navigation view options var navigationViewEnabled = false if ( - navigationOptions?.navigationUiEnabledPreference == NavigationUIEnabledPreference.AUTOMATIC - && GoogleMapsNavigatorHolder.getInitializationState() == GoogleNavigatorInitializationState.INITIALIZED + navigationOptions?.navigationUiEnabledPreference == NavigationUIEnabledPreference.AUTOMATIC && + GoogleMapsNavigatorHolder.getInitializationState() == + GoogleNavigatorInitializationState.INITIALIZED ) { navigationViewEnabled = true } diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigatorHolder.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigatorHolder.kt index 80ebec93..27235f0f 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigatorHolder.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsNavigatorHolder.kt @@ -1,61 +1,168 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.google.maps.flutter.navigation +import android.app.Application +import android.util.DisplayMetrics +import androidx.lifecycle.Observer +import com.google.android.libraries.mapsplatform.turnbyturn.model.NavInfo import com.google.android.libraries.navigation.NavigationApi +import com.google.android.libraries.navigation.NavigationUpdatesOptions +import com.google.android.libraries.navigation.NavigationUpdatesOptions.GeneratedStepImagesType import com.google.android.libraries.navigation.Navigator /** - * Singleton holder for the shared Navigator instance. - * Multiple GoogleMapsNavigationSessionManager instances share the same Navigator. + * Singleton holder for the shared Navigator instance. Multiple GoogleMapsNavigationSessionManager + * instances share the same Navigator. */ enum class GoogleNavigatorInitializationState { - NOT_INITIALIZED, - INITIALIZING, - INITIALIZED, + NOT_INITIALIZED, + INITIALIZING, + INITIALIZED, } object GoogleMapsNavigatorHolder { - @Volatile - private var navigator: Navigator? = null - private var initializationState = GoogleNavigatorInitializationState.NOT_INITIALIZED - private val initializationCallbacks = mutableListOf() - - @Synchronized - fun getNavigator(): Navigator? = navigator - - @Synchronized - fun setNavigator(nav: Navigator?) { - navigator = nav - initializationState = if (nav != null) { - GoogleNavigatorInitializationState.INITIALIZED - } else { - GoogleNavigatorInitializationState.NOT_INITIALIZED - } - } + @Volatile private var navigator: Navigator? = null + private var initializationState = GoogleNavigatorInitializationState.NOT_INITIALIZED + private val initializationCallbacks = mutableListOf() + + // Turn-by-turn navigation service management + private var turnByTurnServiceRegistered = false + private val navInfoObservers = mutableListOf>() + + @Synchronized fun getNavigator(): Navigator? = navigator + + @Synchronized + fun setNavigator(nav: Navigator?) { + navigator = nav + initializationState = + if (nav != null) { + GoogleNavigatorInitializationState.INITIALIZED + } else { + GoogleNavigatorInitializationState.NOT_INITIALIZED + } + } + + @Synchronized + fun getInitializationState(): GoogleNavigatorInitializationState = initializationState + + @Synchronized + fun setInitializationState(state: GoogleNavigatorInitializationState) { + initializationState = state + } - @Synchronized - fun getInitializationState(): GoogleNavigatorInitializationState = initializationState + @Synchronized + fun addInitializationCallback(callback: NavigationApi.NavigatorListener) { + initializationCallbacks.add(callback) + } - @Synchronized - fun setInitializationState(state: GoogleNavigatorInitializationState) { - initializationState = state + @Synchronized + fun getAndClearInitializationCallbacks(): List { + val callbacks = initializationCallbacks.toList() + initializationCallbacks.clear() + return callbacks + } + + @Synchronized + fun registerTurnByTurnService( + application: Application, + numNextStepsToPreview: Int + ): Boolean { + val nav = navigator ?: return false + + if (!turnByTurnServiceRegistered) { + // DisplayMetrics is required to be set for turn-by-turn updates. + // But not used as image generation is disabled. + val displayMetrics = DisplayMetrics() + displayMetrics.density = 2.0f + + // Configure options for navigation updates. + val options = + NavigationUpdatesOptions.builder() + .setNumNextStepsToPreview(numNextStepsToPreview) + .setGeneratedStepImagesType(GeneratedStepImagesType.NONE) + .setDisplayMetrics(displayMetrics) + .build() + + // Attempt to register the service for navigation updates. + val success = + nav.registerServiceForNavUpdates( + application.packageName, + GoogleMapsNavigationNavUpdatesService::class.java.name, + options, + ) + + if (success) { + turnByTurnServiceRegistered = true + } + return success } + return true // Already registered + } - @Synchronized - fun addInitializationCallback(callback: NavigationApi.NavigatorListener) { - initializationCallbacks.add(callback) + @Synchronized + fun addNavInfoObserver(observer: Observer) { + if (!navInfoObservers.contains(observer)) { + navInfoObservers.add(observer) + GoogleMapsNavigationNavUpdatesService.navInfoLiveData.observeForever(observer) } + } - @Synchronized - fun getAndClearInitializationCallbacks(): List { - val callbacks = initializationCallbacks.toList() - initializationCallbacks.clear() - return callbacks + @Synchronized + fun removeNavInfoObserver(observer: Observer) { + if (navInfoObservers.remove(observer)) { + GoogleMapsNavigationNavUpdatesService.navInfoLiveData.removeObserver(observer) } + } - @Synchronized - fun reset() { - navigator = null - initializationState = GoogleNavigatorInitializationState.NOT_INITIALIZED - initializationCallbacks.clear() + @Synchronized + fun unregisterTurnByTurnService(): Boolean { + val nav = navigator ?: return false + + if (turnByTurnServiceRegistered && navInfoObservers.isEmpty()) { + // Remove all observers + for (observer in navInfoObservers.toList()) { + GoogleMapsNavigationNavUpdatesService.navInfoLiveData.removeObserver(observer) + } + navInfoObservers.clear() + + val success = nav.unregisterServiceForNavUpdates() + if (success) { + turnByTurnServiceRegistered = false + } + return success } -} \ No newline at end of file + return true + } + + @Synchronized + fun reset() { + // Clean up turn-by-turn service + if (turnByTurnServiceRegistered) { + for (observer in navInfoObservers.toList()) { + GoogleMapsNavigationNavUpdatesService.navInfoLiveData.removeObserver(observer) + } + navInfoObservers.clear() + navigator?.unregisterServiceForNavUpdates() + turnByTurnServiceRegistered = false + } + + navigator = null + initializationState = GoogleNavigatorInitializationState.NOT_INITIALIZED + initializationCallbacks.clear() + } +} diff --git a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsViewFactory.kt b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsViewFactory.kt index e6e9f6a1..9e3194bf 100644 --- a/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsViewFactory.kt +++ b/android/src/main/kotlin/com/google/maps/flutter/navigation/GoogleMapsViewFactory.kt @@ -24,7 +24,7 @@ import java.util.Objects class GoogleMapsViewFactory( private val viewRegistry: GoogleMapsViewRegistry, private val viewEventApi: ViewEventApi, - private val imageRegistry: ImageRegistry + private val imageRegistry: ImageRegistry, ) : PlatformViewFactory(ViewCreationApi.codec) { override fun create(context: Context, viewId: Int, args: Any?): PlatformView { val params = Objects.requireNonNull(args as ViewCreationOptionsDto) @@ -39,7 +39,7 @@ class GoogleMapsViewFactory( viewId, viewRegistry, viewEventApi, - imageRegistry + imageRegistry, ) } else { return GoogleMapView(context, mapOptions, viewId, viewEventApi, viewRegistry, imageRegistry) 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 7c938d0f..90cd57c8 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 @@ -5702,7 +5702,7 @@ interface NavigationSessionApi { fun isInitialized(): Boolean - fun cleanup() + fun cleanup(resetSession: Boolean) fun showTermsAndConditionsDialog( title: String, @@ -5862,10 +5862,12 @@ interface NavigationSessionApi { codec, ) if (api != null) { - channel.setMessageHandler { _, reply -> + channel.setMessageHandler { message, reply -> + val args = message as List + val resetSessionArg = args[0] as Boolean val wrapped: List = try { - api.cleanup() + api.cleanup(resetSessionArg) listOf(null) } catch (exception: Throwable) { MessagesPigeonUtils.wrapError(exception) diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index cf99de40..b9ec4647 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -14,12 +14,17 @@ limitations under the License. --> - + + + + + @@ -67,6 +72,14 @@ + + + + receivePorts = []; + + for (int i = 0; i < numIsolates; i++) { + final ReceivePort receivePort = ReceivePort(); + receivePorts.add(receivePort); + + await Isolate.spawn( + _isolateVersionCheckMain, + _IsolateData( + rootIsolateToken: rootIsolateToken, + sendPort: receivePort.sendPort, + ), + ); + } + + final List<_IsolateResult> results = []; + for (final receivePort in receivePorts) { + final dynamic result = await receivePort.first; + expect(result, isA<_IsolateResult>()); + results.add(result as _IsolateResult); + } + + for (int i = 0; i < results.length; i++) { + expect( + results[i].error, + isNull, + reason: 'Isolate $i should not throw an error', + ); + expect(results[i].version, isNotNull); + expect(results[i].version!.length, greaterThan(0)); + } + + final String firstVersion = results[0].version!; + for (int i = 1; i < results.length; i++) { + expect( + results[i].version, + equals(firstVersion), + reason: 'All isolates should return the same SDK version', + ); + } + }, + ); + + patrol('Test background service with navigation updates', ( + PatrolIntegrationTester $, + ) async { + if (!Platform.isAndroid) { + return; + } + + await checkLocationDialogAndTosAcceptance($); + + // Request notification permission for foreground service + if (!await Permission.notification.isGranted) { + final Future notificationGranted = + Permission.notification.request(); + + if (await $.native.isPermissionDialogVisible( + timeout: const Duration(seconds: 5), + )) { + // Tap "Allow" button for notification permission + await $.native.tap(Selector(text: 'Allow')); + } + + // Check that the notification permission is granted + await notificationGranted.then((PermissionStatus status) async { + expect(status, PermissionStatus.granted); + }); + } + + final service = FlutterBackgroundService(); + + // Stop any existing service + if (await service.isRunning()) { + service.invoke('stopService'); + await Future.delayed(const Duration(milliseconds: 500)); + } + + final List> backgroundServiceUpdates = []; + final List> mainIsolateUpdates = []; + final Completer backgroundServiceReady = Completer(); + + // Listen to data from background service + service.on('update').listen((event) { + if (event is Map) { + final update = Map.from( + event as Map, + ); + backgroundServiceUpdates.add(update); + + // Signal when background service is initialized + if (update['status'] == 'initialized' && + !backgroundServiceReady.isCompleted) { + backgroundServiceReady.complete(); + } + } + }); + + // Configure and start the background service + // Using isForegroundMode: true to show notification with navigation updates + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: onBackgroundServiceStart, + autoStart: false, + isForegroundMode: true, + notificationChannelId: 'navigation_test_service', + initialNotificationTitle: 'Navigation Test Service', + initialNotificationContent: 'Testing navigation...', + foregroundServiceNotificationId: 999, + ), + iosConfiguration: IosConfiguration( + autoStart: false, + onForeground: onBackgroundServiceStart, + onBackground: onIosBackground, + ), + ); + + final bool serviceStarted = await service.startService(); + expect(serviceStarted, true); + + // Wait for background service to be fully initialized + await backgroundServiceReady.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + throw TimeoutException( + 'Background service failed to initialize within 5 seconds', + ); + }, + ); + + // Give the background service a moment to fully set up its listener + await Future.delayed(const Duration(milliseconds: 500)); + + await GoogleMapsNavigator.initializeNavigationSession(); + + await GoogleMapsNavigator.simulator.setUserLocation( + const LatLng(latitude: 37.79136614772824, longitude: -122.41565900473043), + ); + await Future.delayed(const Duration(seconds: 1)); + + final Destinations destinations = Destinations( + waypoints: [ + NavigationWaypoint.withLatLngTarget( + title: 'California St & Jones St', + target: const LatLng(latitude: 37.791424, longitude: -122.414139), + ), + ], + displayOptions: NavigationDisplayOptions(showDestinationMarkers: false), + ); + + final NavigationRouteStatus status = + await GoogleMapsNavigator.setDestinations(destinations); + expect(status, NavigationRouteStatus.statusOk); + + await GoogleMapsNavigator.startGuidance(); + expect(await GoogleMapsNavigator.isGuidanceRunning(), true); + + final mainIsolateSubscription = + GoogleMapsNavigator.setOnRemainingTimeOrDistanceChangedListener(( + event, + ) { + mainIsolateUpdates.add({ + 'source': 'main_isolate', + 'remainingTime': event.remainingTime, + 'remainingDistance': event.remainingDistance, + 'timestamp': DateTime.now().toIso8601String(), + }); + }); + + await GoogleMapsNavigator.simulator + .simulateLocationsAlongExistingRouteWithOptions( + SimulationOptions(speedMultiplier: 50), + ); + + final bool? bothReceived = await waitForValueMatchingPredicate( + $, + () async { + // Filter out status updates - we only want navigation data updates + final navUpdates = + backgroundServiceUpdates + .where( + (update) => + update.containsKey('remainingTime') && + update.containsKey('remainingDistance'), + ) + .toList(); + return mainIsolateUpdates.isNotEmpty && navUpdates.isNotEmpty; + }, + (bool hasData) => hasData, + maxTries: 100, + delayMs: 100, + ); + + expect( + bothReceived, + true, + reason: + 'Should receive updates from both isolates within 10 seconds. ' + 'Main isolate updates: ${mainIsolateUpdates.length}, ' + 'Background service navigation updates: ${backgroundServiceUpdates.where((u) => u.containsKey('remainingTime')).length}', + ); + + expect( + mainIsolateUpdates.length, + greaterThan(0), + reason: 'Should receive navigation updates on main isolate', + ); + + // Filter background updates to only count navigation data (not status updates) + final backgroundNavUpdates = + backgroundServiceUpdates + .where( + (update) => + update.containsKey('remainingTime') && + update.containsKey('remainingDistance'), + ) + .toList(); + + expect( + backgroundNavUpdates.length, + greaterThan(0), + reason: 'Should receive navigation updates from background service', + ); + + bool mainIsolateHasValidData = false; + bool backgroundServiceHasValidData = false; + + for (final update in mainIsolateUpdates) { + if (update.containsKey('remainingDistance') && + update.containsKey('remainingTime')) { + expect(update['remainingDistance'], isA()); + expect(update['remainingTime'], isA()); + mainIsolateHasValidData = true; + break; + } + } + + for (final update in backgroundNavUpdates) { + if (update.containsKey('remainingDistance') && + update.containsKey('remainingTime')) { + expect(update['remainingDistance'], isA()); + expect(update['remainingTime'], isA()); + backgroundServiceHasValidData = true; + break; + } + } + + expect(mainIsolateHasValidData, true); + expect(backgroundServiceHasValidData, true); + + // Stop the background service + service.invoke('stopService'); + await Future.delayed(const Duration(milliseconds: 500)); + + mainIsolateUpdates.clear(); + + await GoogleMapsNavigator.simulator + .simulateLocationsAlongExistingRouteWithOptions( + SimulationOptions(speedMultiplier: 50), + ); + + final bool? stillReceiving = await waitForValueMatchingPredicate( + $, + () async => mainIsolateUpdates.isNotEmpty, + (bool hasData) => hasData, + maxTries: 50, + delayMs: 100, + ); + + expect( + stillReceiving, + true, + reason: + 'Should receive updates on main isolate after foreground service destroyed within 5 seconds', + ); + + expect( + mainIsolateUpdates.length, + greaterThan(0), + reason: + 'Main isolate should continue receiving navigation updates after foreground service is destroyed', + ); + + bool mainIsolateStillReceivingData = false; + for (final update in mainIsolateUpdates) { + if (update.containsKey('remainingDistance') && + update.containsKey('remainingTime')) { + expect(update['remainingDistance'], isA()); + expect(update['remainingTime'], isA()); + mainIsolateStillReceivingData = true; + break; + } + } + + expect( + mainIsolateStillReceivingData, + true, + reason: + 'Main isolate should still receive valid navigation data after background service destroyed', + ); + + await mainIsolateSubscription.cancel(); + + await GoogleMapsNavigator.stopGuidance(); + await GoogleMapsNavigator.cleanup(); + }); +} + +class _IsolateData { + _IsolateData({required this.rootIsolateToken, required this.sendPort}); + + final RootIsolateToken rootIsolateToken; + final SendPort sendPort; +} + +class _IsolateResult { + _IsolateResult({this.version, this.error}); + + final String? version; + final String? error; +} + +Future _isolateVersionCheckMain(_IsolateData data) async { + try { + BackgroundIsolateBinaryMessenger.ensureInitialized(data.rootIsolateToken); + final String version = await GoogleMapsNavigator.getNavSDKVersion(); + data.sendPort.send(_IsolateResult(version: version)); + } catch (e) { + data.sendPort.send(_IsolateResult(error: e.toString())); + } +} + +/// Background service callback that listens to navigation events. +/// Tests that multiple GoogleMapsNavigationSessionManager instances can +/// listen to the same Navigator without each one initializing its own session. +/// The navigation session is initialized in the main isolate. +@pragma('vm:entry-point') +void onBackgroundServiceStart(ServiceInstance service) async { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + + StreamSubscription? subscription; + + service.on('stopService').listen((event) async { + await subscription?.cancel(); + await GoogleMapsNavigator.cleanup(resetSession: false); + service.stopSelf(); + }); + + try { + // Call createNavigationSession to register this isolate's listeners. + // Since the Navigator is already initialized by the main isolate, + // this will just register listeners without creating a new Navigator. + await GoogleMapsNavigator.initializeNavigationSession(); + + subscription = + GoogleMapsNavigator.setOnRemainingTimeOrDistanceChangedListener(( + event, + ) { + service.invoke('update', { + 'source': 'background_service', + 'remainingTime': event.remainingTime, + 'remainingDistance': event.remainingDistance, + 'timestamp': DateTime.now().toIso8601String(), + }); + + // Update the foreground notification with navigation data + if (service is AndroidServiceInstance) { + final double distanceKm = event.remainingDistance / 1000; + final String distanceText = + distanceKm >= 1 + ? '${distanceKm.toStringAsFixed(1)} km' + : '${event.remainingDistance.toInt()} m'; + + service.setForegroundNotificationInfo( + title: 'Navigation Test Active', + content: 'Distance: $distanceText', + ); + } + }); + + // Signal that the background service is ready and listening + service.invoke('update', { + 'status': 'initialized', + 'timestamp': DateTime.now().toIso8601String(), + }); + } catch (e) { + service.invoke('update', { + 'status': 'error', + 'error': e.toString(), + 'timestamp': DateTime.now().toIso8601String(), + }); + } +} + +@pragma('vm:entry-point') +Future onIosBackground(ServiceInstance service) async { + WidgetsFlutterBinding.ensureInitialized(); + return true; +} diff --git a/example/lib/main.dart b/example/lib/main.dart index f09e7f44..3c6c3066 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -27,6 +27,7 @@ import 'widgets/widgets.dart'; /// The list of pages to show in the Google Maps Navigation demo. final List _allPages = [ const NavigationPage(), + const BackgroundNavigationPage(), const BasicMapPage(), const CameraPage(), const MarkersPage(), diff --git a/example/lib/pages/background_navigation.dart b/example/lib/pages/background_navigation.dart new file mode 100644 index 00000000..f9f20f55 --- /dev/null +++ b/example/lib/pages/background_navigation.dart @@ -0,0 +1,714 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:google_navigation_flutter/google_navigation_flutter.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../utils/utils.dart'; +import '../widgets/widgets.dart'; + +/// Background navigation example demonstrating foreground service +/// with turn-by-turn notifications. +const AndroidNotificationChannel _notificationSettingsAndroid = + AndroidNotificationChannel( + 'navigation_service', + 'Navigation Service', + description: 'Shows navigation updates and distance information', + playSound: false, + enableVibration: false, + importance: Importance.high, + ); + +const DarwinInitializationSettings _notificationSettingsiOS = + DarwinInitializationSettings(requestSoundPermission: false); + +const int _notificationId = 888; + +class BackgroundNavigationPage extends ExamplePage { + const BackgroundNavigationPage({super.key}) + : super( + leading: const Icon(Icons.cloud_queue), + title: 'Background Navigation', + ); + + @override + ExamplePageState createState() => + _BackgroundNavigationPageState(); +} + +class _BackgroundNavigationPageState + extends ExamplePageState { + GoogleNavigationViewController? _navigationViewController; + final FlutterBackgroundService _service = FlutterBackgroundService(); + StreamSubscription? _serviceUpdateSubscription; + + bool _termsAndConditionsAccepted = false; + bool _locationPermissionsAccepted = false; + bool _notificationPermissionAccepted = false; + bool _navigatorInitialized = false; + bool _guidanceRunning = false; + bool _backgroundServiceRunning = false; + bool _backgroundServiceConfigured = false; + bool _cleanupOnExit = false; + + String _serviceStatus = 'Not started'; + int _remainingTime = 0; + int _remainingDistance = 0; + String _currentInstruction = ''; + + LatLng? _userLocation; + + @override + void initState() { + super.initState(); + unawaited(_initialize()); + } + + Future _initialize() async { + final bool isRunning = await _service.isRunning(); + if (isRunning) { + setState(() { + _backgroundServiceRunning = true; + _backgroundServiceConfigured = true; + _serviceStatus = 'Reconnected'; + }); + } + + await _showTermsAndConditionsDialogIfNeeded(); + await _askLocationPermissionsIfNeeded(); + await _requestNotificationPermission(); + + if (_notificationPermissionAccepted) { + await _initializeNotificationChannel(); + } + + if (_backgroundServiceRunning) { + _setupServiceUpdateListener(); + _service.invoke('requestState'); + } + + if (_termsAndConditionsAccepted && _locationPermissionsAccepted) { + await _initializeNavigator(); + } + } + + void _setupServiceUpdateListener() { + _serviceUpdateSubscription?.cancel(); + + _serviceUpdateSubscription = _service.on('update').listen((event) { + if (event is Map) { + final update = Map.from( + event as Map, + ); + _handleServiceUpdate(update); + } + }); + } + + @override + void dispose() { + _serviceUpdateSubscription?.cancel(); + if (_cleanupOnExit) { + if (_backgroundServiceRunning) { + _service.invoke('stopService'); + } + try { + GoogleMapsNavigator.cleanup(); + } catch (e) { + debugPrint('Cleanup error: $e'); + } + } + super.dispose(); + } + + Future _showTermsAndConditionsDialogIfNeeded() async { + _termsAndConditionsAccepted = await requestTermsAndConditionsAcceptance(); + setState(() {}); + } + + Future _askLocationPermissionsIfNeeded() async { + _locationPermissionsAccepted = await requestLocationDialogAcceptance(); + setState(() {}); + } + + Future _requestNotificationPermission() async { + final PermissionStatus status = await Permission.notification.request(); + _notificationPermissionAccepted = status.isGranted; + setState(() {}); + } + + Future _initializeNotificationChannel() async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + + if (Platform.isAndroid) { + await flutterLocalNotificationsPlugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ), + ); + + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(_notificationSettingsAndroid); + } else if (Platform.isIOS) { + await flutterLocalNotificationsPlugin.initialize( + const InitializationSettings(iOS: _notificationSettingsiOS), + ); + } + } + + Future _initializeNavigator() async { + if (!_navigatorInitialized) { + await GoogleMapsNavigator.initializeNavigationSession(); + _navigatorInitialized = await GoogleMapsNavigator.isInitialized(); + _userLocation = + await _navigationViewController?.getMyLocation() ?? + const LatLng(latitude: 37.7749, longitude: -122.4194); + setState(() {}); + } + } + + Future _onViewCreated(GoogleNavigationViewController controller) async { + _navigationViewController = controller; + await controller.setMyLocationEnabled(true); + setState(() {}); + } + + Future _startBackgroundService() async { + if (_backgroundServiceRunning) { + return; + } + + if (!_notificationPermissionAccepted) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Notification permission is required for background service', + ), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + + if (!_backgroundServiceConfigured) { + await _service.configure( + androidConfiguration: AndroidConfiguration( + onStart: onServiceStart, + autoStart: false, + autoStartOnBoot: false, + isForegroundMode: true, + notificationChannelId: _notificationSettingsAndroid.id, + initialNotificationTitle: _notificationSettingsAndroid.name, + initialNotificationContent: 'Initializing navigation tracking...', + foregroundServiceNotificationId: _notificationId, + foregroundServiceTypes: [ + AndroidForegroundType.location, + ], + ), + iosConfiguration: IosConfiguration( + autoStart: false, + onForeground: onServiceStart, + onBackground: _onBackgroundServiceIOS, + ), + ); + + _backgroundServiceConfigured = true; + } + + _setupServiceUpdateListener(); + + setState(() { + _serviceStatus = 'Starting...'; + }); + + final bool serviceStarted = await _service.startService(); + + if (serviceStarted) { + setState(() { + _backgroundServiceRunning = true; + }); + } else { + setState(() { + _serviceStatus = 'Failed to start'; + }); + } + } + + Future _stopBackgroundService() async { + if (_backgroundServiceRunning) { + _service.invoke('stopService'); + _serviceUpdateSubscription?.cancel(); + _serviceUpdateSubscription = null; + await Future.delayed(const Duration(milliseconds: 500)); + setState(() { + _backgroundServiceRunning = false; + _serviceStatus = 'Stopped'; + _remainingTime = 0; + _remainingDistance = 0; + _currentInstruction = ''; + }); + } + } + + void _handleServiceUpdate(Map update) { + if (!mounted) { + return; + } + + setState(() { + if (update.containsKey('status')) { + _serviceStatus = update['status'] as String; + if (_serviceStatus == 'Navigating' && !_guidanceRunning) { + _guidanceRunning = true; + _navigationViewController?.followMyLocation(CameraPerspective.tilted); + } else if (_serviceStatus.startsWith('Arrived')) { + _guidanceRunning = false; + } + } + if (update.containsKey('remainingTime')) { + _remainingTime = (update['remainingTime'] as num).toInt(); + } + if (update.containsKey('remainingDistance')) { + _remainingDistance = (update['remainingDistance'] as num).toInt(); + } + if (update.containsKey('currentInstruction')) { + _currentInstruction = update['currentInstruction'] as String; + } + }); + } + + Future _setSimpleDestination() async { + if (!_navigatorInitialized) { + await _initializeNavigator(); + } + + final LatLng destination = const LatLng( + latitude: 37.791424, + longitude: -122.414139, + ); + + final Destinations destinations = Destinations( + waypoints: [ + NavigationWaypoint.withLatLngTarget( + title: 'California St & Jones St', + target: destination, + ), + ], + displayOptions: NavigationDisplayOptions(showDestinationMarkers: false), + ); + + if (_userLocation != null) { + await GoogleMapsNavigator.simulator.setUserLocation(_userLocation!); + } + + final NavigationRouteStatus status = + await GoogleMapsNavigator.setDestinations(destinations); + + if (status == NavigationRouteStatus.statusOk) { + await GoogleMapsNavigator.startGuidance(); + _guidanceRunning = true; + + await GoogleMapsNavigator.simulator + .simulateLocationsAlongExistingRouteWithOptions( + SimulationOptions(speedMultiplier: 5), + ); + + if (!_backgroundServiceRunning) { + await _startBackgroundService(); + } + + await _navigationViewController?.setNavigationUIEnabled(true); + await _navigationViewController?.followMyLocation( + CameraPerspective.tilted, + ); + + setState(() {}); + } + } + + Future _stopNavigation() async { + await GoogleMapsNavigator.simulator.removeUserLocation(); + await GoogleMapsNavigator.stopGuidance(); + await _navigationViewController?.setNavigationUIEnabled(false); + setState(() { + _guidanceRunning = false; + _remainingTime = 0; + _remainingDistance = 0; + }); + } + + @override + Widget buildPage(BuildContext context, WidgetBuilder builder) { + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: builder(context), + ); + } + + @override + Widget build(BuildContext context) => buildPage( + context, + (BuildContext context) => Column( + children: [ + Expanded( + child: + _navigatorInitialized && _userLocation != null + ? GoogleMapsNavigationView( + onViewCreated: _onViewCreated, + initialCameraPosition: CameraPosition( + target: _userLocation!, + zoom: 15, + ), + initialNavigationUIEnabledPreference: + NavigationUIEnabledPreference.disabled, + ) + : const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Initializing...'), + SizedBox(height: 10), + CircularProgressIndicator(), + ], + ), + ), + ), + _buildStatusPanel(), + _buildControls(), + ], + ), + ); + + Widget _buildStatusPanel() { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.black87, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Background Navigation Status', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.white), + ), + const SizedBox(height: 8), + _buildStatusRow('Service', _serviceStatus), + if (_remainingTime > 0 || _remainingDistance > 0) ...[ + _buildStatusRow( + 'Time', + formatRemainingDuration(Duration(seconds: _remainingTime)), + ), + _buildStatusRow( + 'Distance', + formatRemainingDistance(_remainingDistance), + ), + ], + if (_currentInstruction.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _currentInstruction, + style: const TextStyle( + color: Colors.greenAccent, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatusRow(String label, String value, {bool highlight = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.white70, fontSize: 14)), + Text( + value, + style: TextStyle( + color: highlight ? Colors.green : Colors.white, + fontSize: 14, + fontWeight: highlight ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ); + } + + Widget _buildControls() { + if (!_termsAndConditionsAccepted || !_locationPermissionsAccepted) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text( + 'Terms and conditions and location permissions must be accepted.', + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _initialize, + child: const Text('Accept Terms & Permissions'), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (!_guidanceRunning) + ElevatedButton( + onPressed: _setSimpleDestination, + child: const Text('Start Navigation'), + ), + if (_guidanceRunning) + ElevatedButton( + onPressed: _stopNavigation, + child: const Text('Stop Navigation'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!_backgroundServiceRunning) + ElevatedButton( + onPressed: _startBackgroundService, + child: const Text('Start Background Service'), + ), + if (_backgroundServiceRunning) + ElevatedButton( + onPressed: _stopBackgroundService, + child: const Text('Stop Background Service'), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Checkbox( + value: _cleanupOnExit, + onChanged: (bool? value) { + setState(() { + _cleanupOnExit = value ?? true; + }); + }, + ), + GestureDetector( + onTap: () { + setState(() { + _cleanupOnExit = !_cleanupOnExit; + }); + }, + child: const Text('Stop service on exit'), + ), + ], + ), + ], + ), + ); + } + + @override + Widget buildOverlayContent(BuildContext context) { + return const SizedBox.shrink(); + } +} + +/// Navigation state container. +class _NavigationState { + String status; + int remainingTime; + int remainingDistance; + String currentInstruction; + + _NavigationState({ + this.status = 'Not started', + this.remainingTime = 0, + this.remainingDistance = 0, + this.currentInstruction = '', + }); + + Map toMap() { + return { + 'status': status, + 'remainingTime': remainingTime, + 'remainingDistance': remainingDistance, + 'currentInstruction': currentInstruction, + }; + } +} + +/// Updates service state and notification. +void _updateNavigationState( + _NavigationState state, + ServiceInstance service, + FlutterLocalNotificationsPlugin notificationPlugin, +) { + service.invoke('update', state.toMap()); + + if (Platform.isAndroid && state.currentInstruction.isNotEmpty) { + final String distanceText = formatRemainingDistance( + state.remainingDistance, + ); + final String timeText = formatRemainingDuration( + Duration(seconds: state.remainingTime), + ); + + final bool isArrival = state.currentInstruction.startsWith('Arrived'); + + notificationPlugin.show( + _notificationId, + state.currentInstruction, + isArrival ? 'Arrived' : '$distanceText • $timeText remaining', + NotificationDetails( + android: AndroidNotificationDetails( + _notificationSettingsAndroid.id, + _notificationSettingsAndroid.name, + channelDescription: _notificationSettingsAndroid.description, + icon: '@mipmap/ic_launcher', + ongoing: !isArrival, + autoCancel: isArrival, + playSound: false, + enableVibration: false, + onlyAlertOnce: true, + priority: Priority.high, + importance: Importance.high, + ), + ), + ); + } +} + +/// Background service entry point. Runs in a separate isolate. +@pragma('vm:entry-point') +void onServiceStart(ServiceInstance service) async { + DartPluginRegistrant.ensureInitialized(); + + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + + final navigationState = _NavigationState(); + + StreamSubscription? + remainingTimeSubscription; + StreamSubscription? navInfoSubscription; + StreamSubscription? arrivalSubscription; + + service.on('stopService').listen((event) async { + await remainingTimeSubscription?.cancel(); + await navInfoSubscription?.cancel(); + await arrivalSubscription?.cancel(); + await GoogleMapsNavigator.cleanup(resetSession: false); + service.stopSelf(); + }); + + service.on('requestState').listen((event) { + _updateNavigationState( + navigationState, + service, + flutterLocalNotificationsPlugin, + ); + }); + + try { + service.invoke('update', {'status': 'Initializing...'}); + + await GoogleMapsNavigator.initializeNavigationSession(); + + remainingTimeSubscription = + GoogleMapsNavigator.setOnRemainingTimeOrDistanceChangedListener( + (event) { + navigationState.remainingTime = event.remainingTime.toInt(); + navigationState.remainingDistance = event.remainingDistance.toInt(); + navigationState.status = 'Navigating'; + + _updateNavigationState( + navigationState, + service, + flutterLocalNotificationsPlugin, + ); + }, + remainingTimeThresholdSeconds: 10, + remainingDistanceThresholdMeters: 50, + ); + + navInfoSubscription = GoogleMapsNavigator.setNavInfoListener(( + NavInfoEvent event, + ) { + final navInfo = event.navInfo; + + if (navInfo.currentStep != null) { + final step = navInfo.currentStep!; + + final distanceToStep = navInfo.distanceToCurrentStepMeters; + navigationState.currentInstruction = + distanceToStep != null + ? '${formatRemainingDistance(distanceToStep)} - ${step.fullInstructions}' + : step.fullInstructions; + navigationState.status = 'Navigating'; + + _updateNavigationState( + navigationState, + service, + flutterLocalNotificationsPlugin, + ); + } + }, numNextStepsToPreview: 3); + + arrivalSubscription = GoogleMapsNavigator.setOnArrivalListener((event) { + navigationState.currentInstruction = 'Arrived at ${event.waypoint.title}'; + navigationState.status = 'Arrived at ${event.waypoint.title}'; + navigationState.remainingTime = 0; + navigationState.remainingDistance = 0; + + _updateNavigationState( + navigationState, + service, + flutterLocalNotificationsPlugin, + ); + }); + + service.invoke('update', {'status': 'Ready'}); + } catch (e, stackTrace) { + service.invoke('update', {'status': 'Error: ${e.toString()}'}); + debugPrint('Background service error: $e\n$stackTrace'); + } +} + +@pragma('vm:entry-point') +Future _onBackgroundServiceIOS(ServiceInstance service) async { + return true; +} diff --git a/example/lib/pages/pages.dart b/example/lib/pages/pages.dart index 34e32409..28a4cd54 100644 --- a/example/lib/pages/pages.dart +++ b/example/lib/pages/pages.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export 'background_navigation.dart'; export 'camera.dart'; export 'map.dart'; export 'markers.dart'; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 46cfae9a..2b70fd18 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: device_info_plus: ^11.3.0 flutter: sdk: flutter + flutter_background_service: 5.1.0 + flutter_local_notifications: 18.0.1 google_navigation_flutter: path: ../ http: ^1.1.2 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 2066f325..9dbc7d97 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionManager.swift @@ -148,16 +148,20 @@ class GoogleMapsNavigationSessionManager: NSObject { _session?.navigator != nil } - func cleanup() throws { + func cleanup(resetSession: Bool = true) throws { if _session == nil { throw GoogleMapsNavigationSessionManagerError.sessionNotInitialized } - _session?.locationSimulator?.stopSimulation() - _session?.navigator?.clearDestinations() + _session?.roadSnappedLocationProvider?.remove(self) - _session?.navigator?.isGuidanceActive = false - _session?.isStarted = false - _session = nil + + if resetSession { + _session?.locationSimulator?.stopSimulation() + _session?.navigator?.clearDestinations() + _session?.navigator?.isGuidanceActive = false + _session?.isStarted = false + _session = nil + } } func attachNavigationSessionToMapView(mapId: Int64) throws { diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionMessageHandler.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionMessageHandler.swift index 4a73622f..8d3db8cf 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionMessageHandler.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationSessionMessageHandler.swift @@ -80,8 +80,8 @@ class GoogleMapsNavigationSessionMessageHandler: NavigationSessionApi { GoogleMapsNavigationSessionManager.shared.isInitialized() } - func cleanup() throws { - try GoogleMapsNavigationSessionManager.shared.cleanup() + func cleanup(resetSession: Bool) throws { + try GoogleMapsNavigationSessionManager.shared.cleanup(resetSession: resetSession) } /// Navigation actions 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 150f6fbc..d82bb8ad 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 @@ -4867,7 +4867,7 @@ protocol NavigationSessionApi { abnormalTerminationReportingEnabled: Bool, behavior: TaskRemovedBehaviorDto, completion: @escaping (Result) -> Void) func isInitialized() throws -> Bool - func cleanup() throws + func cleanup(resetSession: Bool) throws func showTermsAndConditionsDialog( title: String, companyName: String, shouldOnlyShowDriverAwarenessDisclaimer: Bool, completion: @escaping (Result) -> Void) @@ -4972,9 +4972,11 @@ class NavigationSessionApiSetup { "dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.cleanup\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - cleanupChannel.setMessageHandler { _, reply in + cleanupChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let resetSessionArg = args[0] as! Bool do { - try api.cleanup() + try api.cleanup(resetSession: resetSessionArg) reply(wrapResult(nil)) } catch { reply(wrapError(error)) diff --git a/lib/src/method_channel/messages.g.dart b/lib/src/method_channel/messages.g.dart index 614725e8..9ad8f4e9 100644 --- a/lib/src/method_channel/messages.g.dart +++ b/lib/src/method_channel/messages.g.dart @@ -6693,7 +6693,7 @@ class NavigationSessionApi { } } - Future cleanup() async { + Future cleanup(bool resetSession) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.cleanup$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = @@ -6702,7 +6702,9 @@ class NavigationSessionApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [resetSession], + ); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { diff --git a/lib/src/method_channel/session_api.dart b/lib/src/method_channel/session_api.dart index 4cde5fed..bff7a621 100644 --- a/lib/src/method_channel/session_api.dart +++ b/lib/src/method_channel/session_api.dart @@ -86,9 +86,17 @@ class NavigationSessionAPIImpl { } /// Cleanup navigation session. - Future cleanup() async { + /// + /// If [resetSession] is true, clears listeners, any existing route waypoints + /// and stops ongoing navigation guidance and simulation. + /// + /// If [resetSession] is false, only unregisters native event listeners without + /// clearing the navigation session. This is useful for background/foreground + /// service implementations where you want to stop receiving events but keep + /// the navigation session active. + Future cleanup({bool resetSession = true}) async { try { - return await _sessionApi.cleanup(); + return await _sessionApi.cleanup(resetSession); } on PlatformException catch (e) { switch (e.code) { case 'sessionNotInitialized': diff --git a/lib/src/navigator/google_navigation_flutter_navigator.dart b/lib/src/navigator/google_navigation_flutter_navigator.dart index 2bda2f25..59428c4b 100644 --- a/lib/src/navigator/google_navigation_flutter_navigator.dart +++ b/lib/src/navigator/google_navigation_flutter_navigator.dart @@ -441,17 +441,24 @@ class GoogleMapsNavigator { /// Cleans up the navigation session. /// - /// Cleans up the navigator's internal state, clearing - /// listeners, any existing route waypoints and stopping ongoing - /// navigation guidance and simulation. - /// - /// On iOS the session is fully deleted and needs to be recreated - /// by calling [GoogleMapsNavigator.initializeNavigationSession]. - /// - /// On Android the session is cleaned up, but never destroyed after the - /// first initialization. - static Future cleanup() async { - await GoogleMapsNavigationPlatform.instance.navigationSessionAPI.cleanup(); + /// By default ([resetSession] is true), cleans up the navigator's internal + /// state, clearing listeners, any existing route waypoints and stopping + /// ongoing navigation guidance and simulation. + /// + /// When [resetSession] is set to false, only unregisters native event + /// listeners without stopping guidance or clearing destinations. This is + /// useful for background/foreground service implementations where you want to + /// stop receiving events but keep the navigation session active. + /// + /// The session is fully deleted when [resetSession] is true and needs to be + /// recreated by calling [GoogleMapsNavigator.initializeNavigationSession]. + /// + /// Note: When [resetSession] is false, you'll need to re-enable listeners + /// by calling [initializeNavigationSession] again to resume receiving events. + static Future cleanup({bool resetSession = true}) async { + await GoogleMapsNavigationPlatform.instance.navigationSessionAPI.cleanup( + resetSession: resetSession, + ); } /// Shows terms and conditions dialog. diff --git a/pigeons/messages.dart b/pigeons/messages.dart index ea4e94fd..9de0342b 100644 --- a/pigeons/messages.dart +++ b/pigeons/messages.dart @@ -1224,7 +1224,7 @@ abstract class NavigationSessionApi { TaskRemovedBehaviorDto behavior, ); bool isInitialized(); - void cleanup(); + void cleanup(bool resetSession); @async bool showTermsAndConditionsDialog( String title, diff --git a/test/google_navigation_flutter_test.mocks.dart b/test/google_navigation_flutter_test.mocks.dart index 2673750d..c5916b3e 100644 --- a/test/google_navigation_flutter_test.mocks.dart +++ b/test/google_navigation_flutter_test.mocks.dart @@ -105,8 +105,8 @@ class MockTestNavigationSessionApi extends _i1.Mock as bool); @override - void cleanup() => super.noSuchMethod( - Invocation.method(#cleanup, []), + void cleanup(bool? resetSession) => super.noSuchMethod( + Invocation.method(#cleanup, [resetSession]), returnValueForMissingStub: null, ); diff --git a/test/messages_test.g.dart b/test/messages_test.g.dart index fdba8422..f94c7885 100644 --- a/test/messages_test.g.dart +++ b/test/messages_test.g.dart @@ -4975,7 +4975,7 @@ abstract class TestNavigationSessionApi { bool isInitialized(); - void cleanup(); + void cleanup(bool resetSession); Future showTermsAndConditionsDialog( String title, @@ -5163,8 +5163,18 @@ abstract class TestNavigationSessionApi { .setMockDecodedMessageHandler(pigeonVar_channel, ( Object? message, ) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.cleanup was null.', + ); + final List args = (message as List?)!; + final bool? arg_resetSession = (args[0] as bool?); + assert( + arg_resetSession != null, + 'Argument for dev.flutter.pigeon.google_navigation_flutter.NavigationSessionApi.cleanup was null, expected non-null bool.', + ); try { - api.cleanup(); + api.cleanup(arg_resetSession!); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e);