diff --git a/android-auto-app/build.gradle.kts b/android-auto-app/build.gradle.kts index ff2a7d217b..803dc07116 100644 --- a/android-auto-app/build.gradle.kts +++ b/android-auto-app/build.gradle.kts @@ -8,7 +8,10 @@ plugins { val buildFromSource: String by project android { + // ..1.7.0/res/values/values.xml:105:5-114:25: AAPT: error: resource android:attr/lStar not found. + // Receiving the above error when using the android auto car library 1.1.0 compileSdkVersion(AndroidVersions.compileSdkVersion) +// compileSdkVersion(31) defaultConfig { applicationId = "com.mapbox.maps.testapp.auto" minSdkVersion(AndroidVersions.minAndroidAutoSdkVersion) @@ -50,10 +53,10 @@ dependencies { implementation(Dependencies.googleCarAppLibrary) implementation(Dependencies.kotlin) implementation(Dependencies.androidxAppCompat) + implementation(Dependencies.androidxCoreKtx) // By default, the Maps SDK uses the Android Location Provider to obtain raw location updates. // And with Android 11, the raw location updates might suffer from precision issue. - // The Maps SDK also comes pre-compiled with support for the [Google's Fused Location Provider](https://developers.google.com/location-context/fused-location-provider) // if that dependency is available. This means, that if your target devices support Google Play // Services, [we recommend adding the Google Play Location Services dependency to your project](https://developers.google.com/android/guides/setup). diff --git a/android-auto-app/src/main/AndroidManifest.xml b/android-auto-app/src/main/AndroidManifest.xml index e631477ba7..3607c550b0 100644 --- a/android-auto-app/src/main/AndroidManifest.xml +++ b/android-auto-app/src/main/AndroidManifest.xml @@ -7,9 +7,6 @@ - - - - - - - + lastGpsLocation = point + if (isTrackingPuck) { + surface?.mapSurface?.getMapboxMap()?.setCamera( + cameraOptions { + center(point) + padding(insets) + } + ) + } + } + /** * Initialise the car camera controller with a map surface. */ - fun init(mapSurface: MapSurface, edgeInsets: EdgeInsets = EdgeInsets(0.0, 0.0, 0.0, 0.0)) { - insets = edgeInsets - surface = mapSurface - surface.getMapboxMap().setCamera( + override fun loaded(mapboxCarMapSurface: MapboxCarMapSurface) { + super.loaded(mapboxCarMapSurface) + this.surface = mapboxCarMapSurface + mapboxCarMapSurface.mapSurface.getMapboxMap().setCamera( cameraOptions { - pitch(INITIAL_PITCH) - zoom(INITIAL_ZOOM) + pitch(previousCameraState?.pitch ?: INITIAL_PITCH) + zoom(previousCameraState?.zoom ?: INITIAL_ZOOM) center(lastGpsLocation) } ) + with(mapboxCarMapSurface.mapSurface.location) { + // Show a 3D location puck + locationPuck = CarLocationPuck.duckLocationPuckLowZoom + enabled = true + addOnIndicatorPositionChangedListener(changePositionListener) + } } - override fun onIndicatorPositionChanged(point: Point) { - lastGpsLocation = point - if (isTrackingPuck) { - surface.getMapboxMap().setCamera( - cameraOptions { - center(point) - padding(insets) - } - ) + override fun detached(mapboxCarMapSurface: MapboxCarMapSurface) { + previousCameraState = mapboxCarMapSurface.mapSurface.getMapboxMap().cameraState + with(mapboxCarMapSurface.mapSurface.location) { + removeOnIndicatorPositionChangedListener(changePositionListener) } + super.detached(mapboxCarMapSurface) + } + + override fun visibleAreaChanged(visibleArea: Rect, edgeInsets: EdgeInsets) { + insets = edgeInsets } - override fun onMapScroll() { + override fun scroll( + mapboxCarMapSurface: MapboxCarMapSurface, + distanceX: Float, + distanceY: Float + ): Boolean { dismissTracking() + return false } /** * Make camera center to location puck and track the location puck's position. */ fun focusOnLocationPuck() { - surface.camera.flyTo( + surface?.mapSurface?.camera?.flyTo( cameraOptions { center(lastGpsLocation) } @@ -67,23 +94,40 @@ class CarCameraController : OnIndicatorPositionChangedListener, OnMapScrollListe } /** - * Adjust the camera's zoom level by the given scale factor. - * - * @param scaleFactor the scale factor to be applied to the camera's current zoom level. + * Function dedicated to zoom in map action buttons. */ - fun zoomBy(scaleFactor: Double) { - val cameraState = surface.getMapboxMap().cameraState - surface.camera.easeTo( - cameraOptions { - zoom(scaleFactor * cameraState.zoom) - } - ) + fun zoomInAction() = scaleEaseBy(ZOOM_ACTION_DELTA) + + /** + * Function dedicated to zoom in map action buttons. + */ + fun zoomOutAction() = scaleEaseBy(-ZOOM_ACTION_DELTA) + + private fun scaleEaseBy(delta: Double) { + val mapSurface = surface?.mapSurface + val fromZoom = mapSurface?.getMapboxMap()?.cameraState?.zoom ?: return + val toZoom = (fromZoom + delta).coerceIn(MIN_ZOOM_OUT, MAX_ZOOM_IN) + mapSurface.camera.easeTo(cameraOptions { zoom(toZoom) }) } companion object { private val HELSINKI = Point.fromLngLat(24.9384, 60.1699) private const val INITIAL_ZOOM = 16.0 private const val INITIAL_PITCH = 75.0 - private const val TAG = "CarCameraController" + + /** + * When zooming the camera by a delta, this is an estimated min-zoom. + */ + private const val MIN_ZOOM_OUT = 6.0 + + /** + * When zooming the camera by a delta, this is an estimated max-zoom. + */ + private const val MAX_ZOOM_IN = 20.0 + + /** + * Simple zoom delta to associate with the zoom action buttons. + */ + private const val ZOOM_ACTION_DELTA = 0.5 } } \ No newline at end of file diff --git a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarJavaInterfaceChecker.java b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarJavaInterfaceChecker.java deleted file mode 100644 index 282f6e594b..0000000000 --- a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarJavaInterfaceChecker.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.mapbox.maps.testapp.auto.car; - -import static com.mapbox.maps.extension.androidauto.MapboxCarUtilsKt.initMapSurface; - -import androidx.car.app.Session; - -import com.mapbox.maps.MapInitOptions; -import com.mapbox.maps.extension.androidauto.MapSurfaceReadyCallback; -import com.mapbox.maps.extension.androidauto.OnMapScaleListener; -import com.mapbox.maps.extension.androidauto.OnMapScrollListener; - -public class CarJavaInterfaceChecker { - - private void carSession(Session session, MapInitOptions mapInitOptions, - OnMapScrollListener scrollListener, - OnMapScaleListener scaleListener, - MapSurfaceReadyCallback mapSurfaceReadyCallback) { - initMapSurface(session, mapSurfaceReadyCallback); - initMapSurface(session, mapInitOptions, mapSurfaceReadyCallback); - initMapSurface(session, mapInitOptions, scrollListener, mapSurfaceReadyCallback); - initMapSurface(session, mapInitOptions, scrollListener, scaleListener, mapSurfaceReadyCallback); - } -} \ No newline at end of file diff --git a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarMapShowcase.kt b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarMapShowcase.kt new file mode 100644 index 0000000000..bb33911a4a --- /dev/null +++ b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarMapShowcase.kt @@ -0,0 +1,58 @@ +package com.mapbox.maps.testapp.auto.car + +import com.mapbox.maps.Style +import com.mapbox.maps.extension.androidauto.MapboxCarMapObserver +import com.mapbox.maps.extension.androidauto.MapboxCarMapSurface +import com.mapbox.maps.extension.style.layers.addLayer +import com.mapbox.maps.extension.style.layers.generated.SkyLayer +import com.mapbox.maps.extension.style.layers.getLayerAs +import com.mapbox.maps.extension.style.sources.addSource +import com.mapbox.maps.extension.style.sources.generated.RasterDemSource +import com.mapbox.maps.extension.style.sources.getSourceAs +import com.mapbox.maps.extension.style.terrain.generated.setTerrain +import com.mapbox.maps.extension.style.terrain.generated.terrain + +class CarMapShowcase : MapboxCarMapObserver { + + override fun loaded(mapboxCarMapSurface: MapboxCarMapSurface) { + with(mapboxCarMapSurface.style) { + updateSkyLayer(mapboxCarMapSurface.carContext.isDarkMode) + updateTerrainLayer() + } + } + + private fun Style.updateSkyLayer(isDarkMode: Boolean) { + var skyLayer = getLayerAs(SKY_LAYER) + if (skyLayer == null) { + skyLayer = SkyLayer(SKY_LAYER) + addLayer(skyLayer) + } + // https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/#paint-sky-sky-atmosphere-sun + // Position of the sun center [a azimuthal angle, p polar angle]. + // Azimuthal is degrees from north, where 0.0 is north. + // Polar is degrees from overhead, where 0.0 is overhead. + if (isDarkMode) { + skyLayer.skyAtmosphereSun(listOf(-50.0, 90.2)) + } else { + skyLayer.skyAtmosphereSun(listOf(0.0, 0.0)) + } + } + + private fun Style.updateTerrainLayer() { + var demSource = getSourceAs(DEM_SOURCE) + if (demSource == null) { + demSource = RasterDemSource.Builder(DEM_SOURCE) + .url(TERRAIN_URL_TILE_RESOURCE) + .tileSize(514) + .build() + addSource(demSource) + } + setTerrain(terrain(DEM_SOURCE) { exaggeration(1.7) }) + } + + companion object { + private const val SKY_LAYER = "sky" + private const val DEM_SOURCE = "mapbox-dem" + private const val TERRAIN_URL_TILE_RESOURCE = "mapbox://mapbox.mapbox-terrain-dem-v1" + } +} \ No newline at end of file diff --git a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapScreen.kt b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapScreen.kt index c31f9d9c7f..1a2b86ae67 100644 --- a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapScreen.kt +++ b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapScreen.kt @@ -6,23 +6,20 @@ import androidx.car.app.Screen import androidx.car.app.model.* import androidx.car.app.navigation.model.NavigationTemplate import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.mapbox.maps.extension.androidauto.MapboxCarMap import com.mapbox.maps.testapp.auto.R -import java.lang.ref.WeakReference /** * Simple demo of how to show a Mapbox Map on the Android Auto screen. */ -class MapScreen(carContext: CarContext) : Screen(carContext) { +class MapScreen( + carContext: CarContext, + val mapboxCarMap: MapboxCarMap +) : Screen(carContext) { private var isInPanMode: Boolean = false - private lateinit var carCameraController: WeakReference - - /** - * Set the map camera controller, so that the UI elements(such as action button) in the template - * can interact with the camera. - */ - fun setMapCameraController(controller: CarCameraController) { - carCameraController = WeakReference(controller) - } + private val carCameraController = CarCameraController() override fun onGetTemplate(): Template { val builder = NavigationTemplate.Builder() @@ -41,7 +38,7 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { ).build() ) .setOnClickListener { - carCameraController.get()?.focusOnLocationPuck() + carCameraController.focusOnLocationPuck() } .build() ) @@ -76,7 +73,7 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { ) .setOnClickListener { // handle zoom out - carCameraController.get()?.zoomBy(0.95) + carCameraController.zoomOutAction() } .build() ) @@ -91,7 +88,7 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { ).build() ) .setOnClickListener { - carCameraController.get()?.zoomBy(1.05) + carCameraController.zoomInAction() } .build() ) @@ -114,4 +111,16 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { return builder.build() } + + init { + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + mapboxCarMap.registerObserver(carCameraController) + } + + override fun onDestroy(owner: LifecycleOwner) { + mapboxCarMap.unregisterObserver(carCameraController) + } + }) + } } \ No newline at end of file diff --git a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapSession.kt b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapSession.kt index f93341809a..1331f871a7 100644 --- a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapSession.kt +++ b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/MapSession.kt @@ -7,83 +7,51 @@ import android.content.res.Configuration import androidx.car.app.Screen import androidx.car.app.ScreenManager import androidx.car.app.Session -import com.mapbox.maps.EdgeInsets -import com.mapbox.maps.MapSurface +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.mapbox.maps.MapInitOptions import com.mapbox.maps.Style -import com.mapbox.maps.extension.androidauto.initMapSurface -import com.mapbox.maps.extension.style.layers.generated.skyLayer -import com.mapbox.maps.extension.style.layers.properties.generated.SkyType -import com.mapbox.maps.extension.style.sources.generated.rasterDemSource -import com.mapbox.maps.extension.style.style -import com.mapbox.maps.extension.style.terrain.generated.terrain -import com.mapbox.maps.plugin.locationcomponent.location -import com.mapbox.maps.testapp.auto.R +import com.mapbox.maps.extension.androidauto.MapboxCarMap /** * Session class for the Mapbox Map sample app for Android Auto. */ class MapSession : Session() { - private lateinit var mapSurface: MapSurface - private val carCameraController = CarCameraController() + private lateinit var mapboxCarMap: MapboxCarMap + private val carMapEnvironment = CarMapShowcase() override fun onCreateScreen(intent: Intent): Screen { - val mapScreen = MapScreen(carContext) - initMapSurface(scrollListener = carCameraController) { surface -> - mapSurface = surface - carCameraController.init( - mapSurface, - EdgeInsets( - carContext.resources.getDimensionPixelSize(R.dimen.map_padding).toDouble(), - 0.0, - 0.0, - 0.0 - ) - ) - mapScreen.setMapCameraController(carCameraController) - loadStyle(surface) - initLocationComponent(surface) - } + val mapScreen = MapScreen(carContext, mapboxCarMap) + return if (carContext.checkSelfPermission(ACCESS_FINE_LOCATION) != PERMISSION_GRANTED) { carContext.getCarService(ScreenManager::class.java).push(mapScreen) RequestPermissionScreen(carContext) } else mapScreen } - private fun loadStyle(surface: MapSurface) { - surface.getMapboxMap().loadStyle( - style(if (carContext.isDarkMode) Style.TRAFFIC_NIGHT else Style.TRAFFIC_DAY) { - +rasterDemSource(SOURCE) { - url(TERRAIN_URL_TILE_RESOURCE) - // 514 specifies padded DEM tile and provides better performance than 512 tiles. - tileSize(514) - } - +terrain(SOURCE) - +skyLayer(SKY_LAYER) { - skyType(SkyType.ATMOSPHERE) - skyAtmosphereSun(listOf(-50.0, 90.2)) - } - } - ) - } - - private fun initLocationComponent(surface: MapSurface) { - // Enable location component plugin with 3D location puck. - surface.location.apply { - // Show a 3D location puck - locationPuck = CarLocationPuck.duckLocationPuckLowZoom - enabled = true - addOnIndicatorPositionChangedListener(carCameraController) - } - } + private fun mapStyle() = if (carContext.isDarkMode) Style.TRAFFIC_NIGHT else Style.TRAFFIC_DAY override fun onCarConfigurationChanged(newConfiguration: Configuration) { - super.onCarConfigurationChanged(newConfiguration) - loadStyle(mapSurface) + mapboxCarMap.updateMapStyle(mapStyle()) } - companion object { - private const val SOURCE = "TERRAIN_SOURCE" - private const val SKY_LAYER = "sky" - private const val TERRAIN_URL_TILE_RESOURCE = "mapbox://mapbox.mapbox-terrain-dem-v1" + init { + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + mapboxCarMap = MapboxCarMap( + MapInitOptions( + context = carContext, + styleUri = mapStyle() + ), + carContext, + lifecycle + ) + mapboxCarMap.registerObserver(carMapEnvironment) + } + + override fun onDestroy(owner: LifecycleOwner) { + mapboxCarMap.unregisterObserver(carMapEnvironment) + } + }) } } \ No newline at end of file diff --git a/android-auto-app/src/main/res/values/dimens.xml b/android-auto-app/src/main/res/values/dimens.xml deleted file mode 100644 index ff7e4b5a71..0000000000 --- a/android-auto-app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - 100dp - \ No newline at end of file diff --git a/android-auto-app/src/main/res/xml/automotive_app_desc.xml b/android-auto-app/src/main/res/xml/automotive_app_desc.xml index 88a21cc8cf..1037a90565 100644 --- a/android-auto-app/src/main/res/xml/automotive_app_desc.xml +++ b/android-auto-app/src/main/res/xml/automotive_app_desc.xml @@ -1,6 +1,4 @@ - - + + \ No newline at end of file diff --git a/extension-androidauto/build.gradle.kts b/extension-androidauto/build.gradle.kts index 9a21f38f60..b6ffb97acd 100644 --- a/extension-androidauto/build.gradle.kts +++ b/extension-androidauto/build.gradle.kts @@ -10,7 +10,7 @@ android { compileSdkVersion(AndroidVersions.compileSdkVersion) defaultConfig { minSdkVersion(AndroidVersions.minAndroidAutoSdkVersion) - targetSdkVersion(AndroidVersions.targetSdkVersion) + targetSdkVersion(AndroidVersions.minAndroidAutoSdkVersion) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -22,11 +22,12 @@ android { } dependencies { - compileOnly(project(":sdk")) + implementation(project(":sdk")) implementation(Dependencies.googleCarAppLibrary) implementation(Dependencies.kotlin) implementation(Dependencies.androidxCoreKtx) implementation(Dependencies.androidxAnnotations) + testImplementation(Dependencies.junit) testImplementation(Dependencies.mockk) testImplementation(Dependencies.androidxTestCore) diff --git a/extension-androidauto/src/main/AndroidManifest.xml b/extension-androidauto/src/main/AndroidManifest.xml index 582dc0a176..846931a7bf 100644 --- a/extension-androidauto/src/main/AndroidManifest.xml +++ b/extension-androidauto/src/main/AndroidManifest.xml @@ -1 +1,15 @@ - + + + + + + + + + + + + diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapLifecycleObserver.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapLifecycleObserver.kt new file mode 100644 index 0000000000..b498b22f88 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapLifecycleObserver.kt @@ -0,0 +1,128 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.mapbox.common.Logger +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.Style +import com.mapbox.maps.extension.observable.eventdata.MapLoadingErrorEventData +import com.mapbox.maps.plugin.delegates.listeners.OnMapLoadErrorListener + +/** + * This class combines Android Auto screen lifecycle events + * with SurfaceCallback lifecycle events. It then + * sets the [CarMapSurfaceOwner] which allows us to register onto + * our own [MapboxCarMapObserver] + */ +internal class CarMapLifecycleObserver internal constructor( + private val carContext: CarContext, + private val carMapSurfaceOwner: CarMapSurfaceOwner, + private val mapInitOptions: MapInitOptions +) : DefaultLifecycleObserver, SurfaceCallback { + + private var mapStyleUri: String = mapInitOptions.styleUri ?: Style.MAPBOX_STREETS + + private val logMapError = object : OnMapLoadErrorListener { + override fun onMapLoadError(eventData: MapLoadingErrorEventData) { + val errorData = "${eventData.type} ${eventData.message}" + Logger.e(TAG, "updateMapStyle onMapLoadError $errorData") + } + } + + /** Screen lifecycle events */ + + override fun onCreate(owner: LifecycleOwner) { + Logger.i(TAG, "onCreate request surface") + carContext.getCarService(AppManager::class.java) + .setSurfaceCallback(this) + } + + /** Surface lifecycle events */ + + override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { + Logger.i(TAG, "onSurfaceAvailable $surfaceContainer") + surfaceContainer.surface?.let { surface -> + val mapSurface = MapSurfaceProvider.create( + carContext, + surface, + mapInitOptions + ) + mapSurface.onStart() + mapSurface.surfaceCreated() + mapSurface.getMapboxMap().loadStyleUri( + mapStyleUri, + onStyleLoaded = { style -> + Logger.i(TAG, "onSurfaceAvailable onStyleLoaded") + mapSurface.surfaceChanged(surfaceContainer.width, surfaceContainer.height) + val carMapSurface = MapboxCarMapSurface(carContext, mapSurface, surfaceContainer, style) + carMapSurfaceOwner.surfaceAvailable(carMapSurface) + }, + onMapLoadErrorListener = logMapError + ) + } + } + + override fun onVisibleAreaChanged(visibleArea: Rect) { + Logger.i(TAG, "onVisibleAreaChanged visibleArea:$visibleArea") + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleArea) + } + + override fun onStableAreaChanged(stableArea: Rect) { + // Have not found a need for this. + // logAndroidAuto("CarMapSurfaceLifecycle Stable area changed stable:$stableArea") + } + + override fun onScroll(distanceX: Float, distanceY: Float) { + Logger.i(TAG, "onScroll $distanceX, $distanceY") + carMapSurfaceOwner.scroll(distanceX, distanceY) + } + + override fun onFling(velocityX: Float, velocityY: Float) { + Logger.i(TAG, "onFling $velocityX, $velocityY") + carMapSurfaceOwner.fling(velocityX, velocityY) + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + Logger.i(TAG, "onScroll $focusX, $focusY, $scaleFactor") + carMapSurfaceOwner.scale(focusX, focusY, scaleFactor) + } + + override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) { + Logger.i(TAG, "onSurfaceDestroyed") + carMapSurfaceOwner.surfaceDestroyed() + } + + /** Map modifiers */ + + fun updateMapStyle(styleUri: String) { + if (this.mapStyleUri == styleUri) return + this.mapStyleUri = styleUri + + Logger.i(TAG, "updateMapStyle $styleUri") + val previousCarMapSurface = carMapSurfaceOwner.mapboxCarMapSurface + val mapSurface = previousCarMapSurface?.mapSurface + mapSurface?.getMapboxMap()?.loadStyleUri( + styleUri, + onStyleLoaded = { style -> + Logger.i(TAG, "updateMapStyle styleAvailable ${style.styleURI}") + val carMapSurface = MapboxCarMapSurface( + carContext, + mapSurface, + previousCarMapSurface.surfaceContainer, + style, + ) + carMapSurfaceOwner.surfaceAvailable(carMapSurface) + }, + onMapLoadErrorListener = logMapError + ) + } + + private companion object { + private const val TAG = "CarMapSurfaceLifecycle" + } +} \ No newline at end of file diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwner.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwner.kt new file mode 100644 index 0000000000..ed0d8e1635 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwner.kt @@ -0,0 +1,190 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import com.mapbox.common.Logger +import com.mapbox.maps.CameraOptions +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.ScreenCoordinate +import com.mapbox.maps.plugin.animation.camera +import java.util.concurrent.CopyOnWriteArraySet + +/** + * @see MapboxCarMap to create new map experiences. + * + * Maintains the surface state for [MapboxCarMap]. + */ +internal class CarMapSurfaceOwner { + + internal var mapboxCarMapSurface: MapboxCarMapSurface? = null + private set + internal var visibleArea: Rect? = null + private set + internal var edgeInsets: EdgeInsets? = null + private set + internal var visibleCenter: ScreenCoordinate = visibleCenter() + private set + + private val carMapObservers = CopyOnWriteArraySet() + + fun registerObserver(mapboxCarMapObserver: MapboxCarMapObserver) { + carMapObservers.add(mapboxCarMapObserver) + Logger.i(TAG, "registerObserver + 1 = ${carMapObservers.size}") + + mapboxCarMapSurface?.let { carMapSurface -> + mapboxCarMapObserver.loaded(carMapSurface) + } + ifNonNull(mapboxCarMapSurface, visibleArea, edgeInsets) { _, area, edge -> + Logger.i(TAG, "registerObserver visibleAreaChanged") + mapboxCarMapObserver.visibleAreaChanged(area, edge) + } + } + + fun unregisterObserver(mapboxCarMapObserver: MapboxCarMapObserver) { + carMapObservers.remove(mapboxCarMapObserver) + mapboxCarMapSurface?.let { mapboxCarMapObserver.detached(it) } + Logger.i(TAG, "unregisterObserver - 1 = ${carMapObservers.size}") + } + + fun clearObservers() { + val oldCarMapSurface = this.mapboxCarMapSurface + oldCarMapSurface?.let { carMapObservers.forEach { it.detached(oldCarMapSurface) } } + carMapObservers.clear() + } + + fun surfaceAvailable(mapboxCarMapSurface: MapboxCarMapSurface) { + Logger.i(TAG, "surfaceAvailable") + val oldCarMapSurface = this.mapboxCarMapSurface + this.mapboxCarMapSurface = mapboxCarMapSurface + oldCarMapSurface?.let { carMapObservers.forEach { it.detached(oldCarMapSurface) } } + carMapObservers.forEach { it.loaded(mapboxCarMapSurface) } + notifyVisibleAreaChanged() + } + + fun surfaceDestroyed() { + Logger.i(TAG, "surfaceDestroyed") + val detachSurface = this.mapboxCarMapSurface + detachSurface?.mapSurface?.onStop() + detachSurface?.mapSurface?.surfaceDestroyed() + detachSurface?.mapSurface?.onDestroy() + this.mapboxCarMapSurface = null + detachSurface?.let { carMapObservers.forEach { it.detached(detachSurface) } } + } + + fun surfaceVisibleAreaChanged(visibleArea: Rect) { + Logger.i(TAG, "surfaceVisibleAreaChanged") + this.visibleArea = visibleArea + notifyVisibleAreaChanged() + } + + private fun notifyVisibleAreaChanged() { + this.edgeInsets = visibleArea?.edgeInsets() + this.visibleCenter = visibleCenter() + ifNonNull(mapboxCarMapSurface, visibleArea, edgeInsets) { _, area, edge -> + Logger.i(TAG, "notifyVisibleAreaChanged $area $edge") + carMapObservers.forEach { + it.visibleAreaChanged(area, edge) + } + } + } + + private inline fun ifNonNull( + r1: R1?, + r2: R2?, + r3: R3?, + func: (R1, R2, R3) -> T + ): T? = if (r1 != null && r2 != null && r3 != null) { + func(r1, r2, r3) + } else { + null + } + + private fun Rect.edgeInsets(): EdgeInsets? { + val surfaceContainer = mapboxCarMapSurface?.surfaceContainer ?: return null + return EdgeInsets( + top.toDouble(), + left.toDouble(), + (surfaceContainer.height - bottom).toDouble(), + (surfaceContainer.width - right).toDouble() + ) + } + + private fun visibleCenter(): ScreenCoordinate { + return visibleArea?.run(rectCenterMapper) + ?: mapboxCarMapSurface?.run(surfaceContainerCenterMapper) + ?: ScreenCoordinate(0.0, 0.0) + } + + fun scroll(distanceX: Float, distanceY: Float) { + val carMapSurface = mapboxCarMapSurface ?: return + val handled = carMapObservers.any { it.scroll(carMapSurface, distanceX, distanceY) } + if (handled) return + + with(carMapSurface.mapSurface.getMapboxMap()) { + val fromCoordinate = visibleCenter + dragStart(fromCoordinate) + val toCoordinate = ScreenCoordinate( + fromCoordinate.x - distanceX, + fromCoordinate.y - distanceY + ) + Logger.i(TAG, "scroll from $fromCoordinate to $toCoordinate") + setCamera(getDragCameraOptions(fromCoordinate, toCoordinate)) + dragEnd() + } + } + + fun fling(velocityX: Float, velocityY: Float) { + val carMapSurface = mapboxCarMapSurface ?: return + val handled = carMapObservers.any { it.fling(carMapSurface, velocityX, velocityY) } + if (handled) return + + Logger.i(TAG, "fling $velocityX, $velocityY") + // TODO implement fling + // https://github.com/mapbox/mapbox-navigation-android-examples/issues/67 + } + + fun scale(focusX: Float, focusY: Float, scaleFactor: Float) { + val carMapSurface = mapboxCarMapSurface ?: return + with(carMapSurface.mapSurface.getMapboxMap()) { + val fromZoom = cameraState.zoom + val toZoom = fromZoom - (1.0 - scaleFactor.toDouble()) + val anchor = ScreenCoordinate( + focusX.toDouble(), + focusY.toDouble() + ) + val handled = carMapObservers.any { it.scale(carMapSurface, anchor, fromZoom, toZoom) } + if (handled) return + + val cameraOptions = CameraOptions.Builder() + .zoom(toZoom) + .anchor(anchor) + .build() + + Logger.i(TAG, "scale with $focusX, $focusY $scaleFactor -> $fromZoom $toZoom") + if (scaleFactor == DOUBLE_TAP_SCALE_FACTOR) { + carMapSurface.mapSurface.camera.easeTo(cameraOptions) + } else { + setCamera(cameraOptions) + } + } + } + + private companion object { + private const val TAG = "CarMapSurfaceOwner" + + private val rectCenterMapper = { rect: Rect -> + ScreenCoordinate(rect.exactCenterX().toDouble(), rect.exactCenterY().toDouble()) + } + + private val surfaceContainerCenterMapper = { carMapSurface: MapboxCarMapSurface -> + val container = carMapSurface.surfaceContainer + ScreenCoordinate(container.width / 2.0, container.height / 2.0) + } + + /** + * This appears to be undocumented from android auto. But when running from the emulator, + * you can double tap the screen and zoom in to reproduce this value. + * It is a jarring experience if you do not easeTo the zoom. + */ + private const val DOUBLE_TAP_SCALE_FACTOR = 2.0f + } +} \ No newline at end of file diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapSurfaceProvider.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapSurfaceProvider.kt new file mode 100644 index 0000000000..fc0788c39a --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapSurfaceProvider.kt @@ -0,0 +1,17 @@ +package com.mapbox.maps.extension.androidauto + +import android.content.Context +import android.view.Surface +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapSurface + +/** + * This provider is needed for creating unit tests. + */ +internal object MapSurfaceProvider { + fun create( + context: Context, + surface: Surface, + mapInitOptions: MapInitOptions + ) = MapSurface(context, surface, mapInitOptions) +} \ No newline at end of file diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMap.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMap.kt new file mode 100644 index 0000000000..63844d9b1a --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMap.kt @@ -0,0 +1,84 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import androidx.car.app.CarContext +import androidx.lifecycle.Lifecycle +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.Style + +/** + * This is the main entry point for controlling the Mapbox car map surface. + * + * You can add the [MapboxCarMap] to your entire [androidx.car.app.Session] using the [Lifecycle]. + * And then any [androidx.car.app.Screen] that is using a supported template will automatically + * show the Mapbox map. You can also, specify a [MapboxCarMap] for each individual + * [androidx.car.app.Screen] by constructing the [MapboxCarMap] with the screen Lifecycle. + * + * Supported templates include: + * - [androidx.car.app.navigation.model.NavigationTemplate] + * - [androidx.car.app.navigation.model.RoutePreviewNavigationTemplate] + * - [androidx.car.app.navigation.model.PlaceListNavigationTemplate] + * + * Customize your [MapboxCarMap] with your own implementations of [MapboxCarMapObserver]. + * Use the [registerObserver] and [unregisterObserver] functions to load and detach the observers. + * + * @param mapInitOptions to initialize the [MapboxCarMapSurface] + * @param carContext for accessing car services + * @param lifecycle to prevent memory leaks and support [MapboxCarMapObserver] lifecycles. + */ +class MapboxCarMap( + mapInitOptions: MapInitOptions, + carContext: CarContext, + lifecycle: Lifecycle +) { + private val carMapSurfaceSession = CarMapSurfaceOwner() + private val carMapLifecycleObserver = CarMapLifecycleObserver( + carContext, + carMapSurfaceSession, + mapInitOptions + ) + + val mapboxCarMapSurface: MapboxCarMapSurface? + get() = carMapSurfaceSession.mapboxCarMapSurface + val visibleArea: Rect? + get() = carMapSurfaceSession.visibleArea + val edgeInsets: EdgeInsets? + get() = carMapSurfaceSession.edgeInsets + + init { + lifecycle.addObserver(carMapLifecycleObserver) + } + + /** + * @param mapboxCarMapObserver implements the desired mapbox car experiences + */ + fun registerObserver(mapboxCarMapObserver: MapboxCarMapObserver) = apply { + carMapSurfaceSession.registerObserver(mapboxCarMapObserver) + } + + /** + * @param mapboxCarMapObserver the instance used in [registerObserver] + */ + fun unregisterObserver(mapboxCarMapObserver: MapboxCarMapObserver) { + carMapSurfaceSession.unregisterObserver(mapboxCarMapObserver) + } + + /** + * Optional function to clear all observers registered through [registerObserver] + */ + fun clearObservers() { + carMapSurfaceSession.clearObservers() + } + + /** + * When the style changes, this will cause all registered observers to trigger + * [MapboxCarMapObserver.detached]. The new style will load and trigger + * [MapboxCarMapObserver.loaded]. + * + * @param styleUri The styleUri will update the [MapboxCarMapSurface]. + */ + fun updateMapStyle(styleUri: String) { + carMapLifecycleObserver.updateMapStyle(styleUri) + } +} \ No newline at end of file diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapObserver.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapObserver.kt new file mode 100644 index 0000000000..f72f752be0 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapObserver.kt @@ -0,0 +1,119 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import androidx.car.app.SurfaceCallback +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.ScreenCoordinate + +/** + * Many downstream services will not work until the surface has been created and the map has + * loaded. This interface allows you to create custom Mapbox experiences for the car. + */ +interface MapboxCarMapObserver { + + /** + * Called when a [MapboxCarMapSurface] has been loaded. + * You can assume there will only be a single surface at a time. + * + * @see [MapboxCarMap.registerObserver] + * + * @param mapboxCarMapSurface loaded and ready to use car map surface + */ + fun loaded(mapboxCarMapSurface: MapboxCarMapSurface) { + // No op by default + } + + /** + * Called when the car library updates the visible regions for the surface. For example, this + * is triggered when the action buttons come in and out of visibility. + * You can assume this will be called after [loaded]. + * + * @param visibleArea the visible area provided by the host + * @param edgeInsets distance from each side of the screen that creates the [visibleArea] + */ + fun visibleAreaChanged(visibleArea: Rect, edgeInsets: EdgeInsets) { + // No op by default + } + + /** + * Allows you to implement or observe the map scroll gesture handler. The surface is [loaded] + * before this can be triggered. + * + * @see [SurfaceCallback.onScroll] for instructions to enable. + * + * @param mapboxCarMapSurface loaded and ready car map surface + * @param distanceX the distance in pixels along the X axis + * @param distanceY the distance in pixels along the Y axis + * + * @return true when the fling scroll was handled, false will trigger the default handler + */ + fun scroll( + mapboxCarMapSurface: MapboxCarMapSurface, + distanceX: Float, + distanceY: Float + ): Boolean { + // By default, scroll is handled internally + return false + } + + /** + * Allows you to implement or observe the map fling gesture handler. The surface is [loaded] + * before this can be triggered. + * + * @see [SurfaceCallback.onFling] for instructions to enable. + * + * @param mapboxCarMapSurface loaded and ready car map surface + * @param velocityX the velocity of this fling measured in pixels per second along the x axis + * @param velocityY the velocity of this fling measured in pixels per second along the y axis + * + * @return true when the fling call was handled, false will trigger the default handler + */ + fun fling( + mapboxCarMapSurface: MapboxCarMapSurface, + velocityX: Float, + velocityY: Float + ): Boolean { + // By default, fling is handled internally + return false + } + + /** + * Allows you to implement or observe the map scale gesture handler. The surface is [loaded] + * before this can be triggered. + * + * @see [SurfaceCallback.onScroll] for instructions to enable. + * + * @param mapboxCarMapSurface loaded and ready car map surface + * @param anchor the focus point in pixels for the zooming gesture + * @param fromZoom the current zoom of the Mapbox camera + * @param toZoom the new zoom that will be set if the function returns false + * + * @return true when the scale call was handled, false will trigger the default handler + */ + @Suppress("LongParameterList") + fun scale( + mapboxCarMapSurface: MapboxCarMapSurface, + anchor: ScreenCoordinate, + fromZoom: Double, + toZoom: Double + ): Boolean { + // By default, scale is handled internally + return false + } + + /** + * Called when a [MapboxCarMapSurface] has been detached from this observer. Some examples that + * can cause this to detach are: + * - [MapboxCarMap] lifecycle is destroyed + * - [MapboxCarMap.updateMapStyle] has been called + * - This observer has been unregistered with [MapboxCarMap.unregisterObserver] + * + * You can assume that there was a corresponding call to [loaded] with the same + * [MapboxCarMapObserver] instance. + * + * @param mapboxCarMapSurface loaded and ready car map surface + */ + fun detached(mapboxCarMapSurface: MapboxCarMapSurface) { + // No op by default + } +} \ No newline at end of file diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapSurface.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapSurface.kt new file mode 100644 index 0000000000..4dc6111e52 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapSurface.kt @@ -0,0 +1,35 @@ +package com.mapbox.maps.extension.androidauto + +import androidx.car.app.CarContext +import androidx.car.app.SurfaceContainer +import com.mapbox.maps.MapSurface +import com.mapbox.maps.Style + +/** + * This contains the Android Auto head unit map information. + * @see MapboxCarMap.registerObserver + * + * @property carContext reference to the context provided to the [MapboxCarMap] + * @property mapSurface Mapbox controllable interface + * @property surfaceContainer A container for the Surface created by the car. + * @property style reference to the Mapbox Style + */ +class MapboxCarMapSurface internal constructor( + val carContext: CarContext, + val mapSurface: MapSurface, + val surfaceContainer: SurfaceContainer, + val style: Style +) { + /** + * Get a string representation of the map surface. + * + * @return the string representation + */ + override fun toString(): String { + return "MapboxCarMapSurface(carContext=$carContext," + + " mapSurface=$mapSurface," + + " surfaceContainer=$surfaceContainer," + + " style=$style" + + ")" + } +} \ No newline at end of file diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarUtils.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarUtils.kt deleted file mode 100644 index b1a1a02ad6..0000000000 --- a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarUtils.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.mapbox.maps.extension.androidauto - -import android.graphics.Rect -import androidx.car.app.AppManager -import androidx.car.app.Session -import androidx.car.app.SurfaceCallback -import androidx.car.app.SurfaceContainer -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import com.mapbox.common.Logger -import com.mapbox.maps.* - -/** - * MapSurface ready callback, will be called when the MapSurface is created successfully. - */ -@MapboxExperimental -fun interface MapSurfaceReadyCallback { - /** - * Map surface is ready. - */ - fun onMapSurfaceReady(mapSurface: MapSurface) -} - -/** - * Fired when there's a map scroll event. - */ -@MapboxExperimental -fun interface OnMapScrollListener { - /** - * Map scroll event fired. - */ - fun onMapScroll() -} - -/** - * Fired when there's a map scale event. - */ -@MapboxExperimental -fun interface OnMapScaleListener { - /** - * Map scroll event fired. - */ - fun onMapScale() -} - -/** - * Init Mapbox Map surface for the car app. - * - * @param mapInitOptions options to initialise a MapboxMap - * @param scrollListener - * @param scaleListener - * @param mapSurfaceReadyCallback - */ -@MapboxExperimental -@JvmOverloads -fun Session.initMapSurface( - mapInitOptions: MapInitOptions = MapInitOptions(carContext), - scrollListener: OnMapScrollListener? = null, - scaleListener: OnMapScaleListener? = null, - mapSurfaceReadyCallback: MapSurfaceReadyCallback -) { - var mapSurface: MapSurface? = null - val surfaceCallback: SurfaceCallback = object : SurfaceCallback { - override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { - synchronized(this) { - Logger.i(TAG, "Surface available $surfaceContainer") - surfaceContainer.surface?.let { surface -> - mapSurface = MapSurface(carContext, surface, mapInitOptions).also { surface -> - surface.surfaceCreated() - surface.surfaceChanged(surfaceContainer.width, surfaceContainer.height) - mapSurfaceReadyCallback.onMapSurfaceReady(surface) - } - } - } - } - - override fun onVisibleAreaChanged(visibleArea: Rect) { - synchronized(this) { - Logger.i( - TAG, - "Visible area changed $visibleArea" - ) - } - } - - override fun onStableAreaChanged(stableArea: Rect) { - synchronized(this) { - Logger.i( - TAG, - "Stable area changed $stableArea" - ) - } - } - - override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) { - synchronized(this) { - Logger.i(TAG, "Surface destroyed") - mapSurface?.surfaceDestroyed() - } - } - - override fun onScroll(distanceX: Float, distanceY: Float) { - Logger.d(TAG, "onScroll $distanceX, $distanceY") - synchronized(this) { - mapSurface?.onScroll(distanceX, distanceY) - scrollListener?.onMapScroll() - } - } - - override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { - Logger.d(TAG, "onScroll $focusX, $focusY, $scaleFactor") - synchronized(this) { - mapSurface?.onScale(focusX, focusY, scaleFactor) - scaleListener?.onMapScale() - } - } - } - - lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - Logger.i(TAG, "SurfaceRenderer created") - synchronized(this) { - carContext.getCarService(AppManager::class.java) - .setSurfaceCallback(surfaceCallback) - } - } - - override fun onStart(owner: LifecycleOwner) { - Logger.i(TAG, "onStart") - synchronized(this) { - mapSurface?.onStart() - } - } - - override fun onStop(owner: LifecycleOwner) { - Logger.i(TAG, "onStop") - synchronized(this) { - mapSurface?.onStop() - } - } - - override fun onDestroy(owner: LifecycleOwner) { - Logger.i(TAG, "onDestroy") - synchronized(this) { - mapSurface?.onDestroy() - } - } - } - ) -} - -private fun MapSurface.onScroll(distanceX: Float, distanceY: Float) { - Logger.i(TAG, "handleScroll $distanceX, $distanceY") - synchronized(this) { - val centerScreen = ScreenCoordinate(0.0, 0.0) - getMapboxMap().apply { - dragStart(centerScreen) - val newCamera = getMapboxMap().getDragCameraOptions( - centerScreen, - ScreenCoordinate( - centerScreen.x - distanceX, - centerScreen.y - distanceY - ) - ) - setCamera(newCamera) - dragEnd() - } - } -} - -private fun MapSurface.onScale(focusX: Float, focusY: Float, scaleFactor: Float) { - Logger.i(TAG, "handleScale $focusX, $focusY. $scaleFactor") - synchronized(this) { - val cameraState = getMapboxMap().cameraState - Logger.i(TAG, "setting zoom ${cameraState.zoom * scaleFactor}") - val newZoom = cameraState.zoom * scaleFactor - val cameraOptionsBuilder = CameraOptions.Builder().zoom(newZoom) - if (focusX >= 0 && focusY >= 0) { - cameraOptionsBuilder.anchor( - ScreenCoordinate( - focusX.toDouble(), - focusY.toDouble() - ) - ) - } - getMapboxMap().setCamera(cameraOptionsBuilder.build()) - } -} - -private const val TAG = "MapboxCarMap" \ No newline at end of file diff --git a/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapLifecycleObserverTest.kt b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapLifecycleObserverTest.kt new file mode 100644 index 0000000000..5c09e79f3c --- /dev/null +++ b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapLifecycleObserverTest.kt @@ -0,0 +1,165 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.lifecycle.LifecycleOwner +import com.mapbox.common.Logger +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapSurface +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.Style +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CarMapLifecycleObserverTest { + + private val carContext: CarContext = mockk(relaxed = true) + private val carMapSurfaceOwner: CarMapSurfaceOwner = mockk() + private val mapboxCarOptions: MapInitOptions = mockk(relaxed = true) + private val testMapSurface: MapSurface = mockk(relaxed = true) + + private val carMapLifecycleObserver = CarMapLifecycleObserver( + carContext, + carMapSurfaceOwner, + mapboxCarOptions + ) + + @Before + fun setup() { + mockkStatic(Logger::class) + every { Logger.i(any(), any()) } just Runs + mockkObject(MapSurfaceProvider) + every { MapSurfaceProvider.create(any(), any(), any()) } returns testMapSurface + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `onCreate should request the map surface with the SurfaceCallback`() { + val lifecycleOwner = mockk() + val surfaceCallback = slot() + every { carContext.getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallback)) } just Runs + } + + carMapLifecycleObserver.onCreate(lifecycleOwner) + + assertTrue(surfaceCallback.isCaptured) + assertEquals(surfaceCallback.captured, carMapLifecycleObserver) + } + + @Test + fun `onSurfaceAvailable should load the MapboxMap`() { + val mapboxMap = mockk(relaxed = true) + every { testMapSurface.getMapboxMap() } returns mapboxMap + + carMapLifecycleObserver.onSurfaceAvailable( + mockk { + every { surface } returns mockk() + } + ) + + verifyOrder { + testMapSurface.onStart() + testMapSurface.surfaceCreated() + testMapSurface.getMapboxMap() + mapboxMap.loadStyleUri(any(), any(), any()) + } + } + + @Test + fun `onSurfaceAvailable should notify surfaceAvailable when style is loaded`() { + every { testMapSurface.getMapboxMap() } returns mockk(relaxed = true) { + every { loadStyleUri(any(), any(), any()) } answers { + secondArg().onStyleLoaded(mockk()) + } + } + val carMapSurfaceSlot = slot() + every { carMapSurfaceOwner.surfaceAvailable(capture(carMapSurfaceSlot)) } just Runs + + carMapLifecycleObserver.onSurfaceAvailable( + mockk { + every { surface } returns mockk() + every { width } returns 800 + every { height } returns 400 + } + ) + + verifyOrder { + testMapSurface.surfaceChanged(800, 400) + carMapSurfaceOwner.surfaceAvailable(any()) + } + } + + @Test + fun `onVisibleAreaChanged should notify carMapSurfaceOwner surfaceVisibleAreaChanged`() { + val visibleRect = mockk() + every { carMapSurfaceOwner.surfaceVisibleAreaChanged(any()) } just Runs + + carMapLifecycleObserver.onVisibleAreaChanged(visibleRect) + + verify(exactly = 1) { carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) } + } + + @Test + fun `onStableAreaChanged should not do anything`() { + carMapLifecycleObserver.onStableAreaChanged(mockk()) + + verify(exactly = 0) { carMapSurfaceOwner.surfaceVisibleAreaChanged(any()) } + verify(exactly = 0) { carMapSurfaceOwner.surfaceDestroyed() } + } + + @Test + fun `onSurfaceDestroyed should notify carMapSurfaceOwner surfaceDestroyed`() { + every { carMapSurfaceOwner.surfaceDestroyed() } just Runs + + carMapLifecycleObserver.onSurfaceDestroyed(mockk()) + + verify(exactly = 1) { carMapSurfaceOwner.surfaceDestroyed() } + } + + @Test + fun `updateMapStyle should notify surfaceAvailable when style is loaded`() { + val previousMapSurface = mockk { + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mockk(relaxed = true) { + every { loadStyleUri(any(), any(), any()) } answers { + secondArg().onStyleLoaded( + mockk { every { styleURI } returns "test-map-style-loaded" } + ) + } + } + } + every { surfaceContainer } returns mockk() + } + every { carMapSurfaceOwner.mapboxCarMapSurface } returns previousMapSurface + val carMapSurfaceSlot = slot() + every { carMapSurfaceOwner.surfaceAvailable(capture(carMapSurfaceSlot)) } just Runs + + carMapLifecycleObserver.updateMapStyle("test-map-style") + + val mapSurface = previousMapSurface.mapSurface + verify(exactly = 0) { mapSurface.surfaceChanged(any(), any()) } + verify(exactly = 1) { carMapSurfaceOwner.surfaceAvailable(any()) } + assertEquals("test-map-style-loaded", carMapSurfaceSlot.captured.style.styleURI) + assertEquals(previousMapSurface.mapSurface, carMapSurfaceSlot.captured.mapSurface) + } +} \ No newline at end of file diff --git a/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwnerTest.kt b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwnerTest.kt new file mode 100644 index 0000000000..bcb8494068 --- /dev/null +++ b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwnerTest.kt @@ -0,0 +1,567 @@ +@file:Suppress("NoMockkVerifyImport") + +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import com.mapbox.maps.CameraOptions +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapSurface +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.ScreenCoordinate +import com.mapbox.maps.extension.androidauto.testing.ShadowLogger +import com.mapbox.maps.plugin.animation.CameraAnimationsPlugin +import com.mapbox.maps.plugin.animation.camera +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowLogger::class]) +class CarMapSurfaceOwnerTest { + + private val carMapSurfaceOwner = CarMapSurfaceOwner() + + @Test + fun `should not notify observer loaded when there is no surface`() { + val firstObserver: MapboxCarMapObserver = mockk(relaxed = true) + val secondObserver: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(firstObserver) + carMapSurfaceOwner.registerObserver(secondObserver) + carMapSurfaceOwner.unregisterObserver(firstObserver) + carMapSurfaceOwner.clearObservers() + + verify(exactly = 0) { firstObserver.loaded(any()) } + verify(exactly = 0) { secondObserver.loaded(any()) } + } + + @Test + fun `should not notify observer detached when there is no surface`() { + val firstObserver: MapboxCarMapObserver = mockk(relaxed = true) + val secondObserver: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(firstObserver) + carMapSurfaceOwner.registerObserver(secondObserver) + carMapSurfaceOwner.unregisterObserver(firstObserver) + carMapSurfaceOwner.clearObservers() + + verify(exactly = 0) { firstObserver.detached(any()) } + verify(exactly = 0) { secondObserver.detached(any()) } + } + + @Test + fun `surfaceAvailable should notify observers that map is loaded`() { + val firstObserver: MapboxCarMapObserver = mockk(relaxed = true) + val secondObserver: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(firstObserver) + carMapSurfaceOwner.registerObserver(secondObserver) + + val mapboxCarMapSurface: MapboxCarMapSurface = mockk(relaxed = true) + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + + verify(exactly = 1) { firstObserver.loaded(mapboxCarMapSurface) } + verify(exactly = 1) { secondObserver.loaded(mapboxCarMapSurface) } + } + + @Test + fun `surfaceAvailable should not notify visibleAreaChanged when visible area is null`() { + val firstObserver: MapboxCarMapObserver = mockk(relaxed = true) + val secondObserver: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(firstObserver) + carMapSurfaceOwner.registerObserver(secondObserver) + + val mapboxCarMapSurface: MapboxCarMapSurface = mockk(relaxed = true) + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + + verify(exactly = 0) { firstObserver.visibleAreaChanged(any(), any()) } + verify(exactly = 0) { secondObserver.visibleAreaChanged(any(), any()) } + } + + @Test + fun `surfaceVisibleAreaChanged should notify visibleAreaChanged when surface is available`() { + val firstObserver: MapboxCarMapObserver = mockk(relaxed = true) + val secondObserver: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(firstObserver) + carMapSurfaceOwner.registerObserver(secondObserver) + + val mapboxCarMapSurface: MapboxCarMapSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + } + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + val visibleRect: Rect = mockk(relaxed = true) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + + verify(exactly = 1) { firstObserver.visibleAreaChanged(any(), any()) } + verify(exactly = 1) { secondObserver.visibleAreaChanged(any(), any()) } + } + + @Test + fun `surfaceVisibleAreaChanged should not notify visibleAreaChanged when surface is not available`() { + val firstObserver: MapboxCarMapObserver = mockk(relaxed = true) + val secondObserver: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(firstObserver) + carMapSurfaceOwner.registerObserver(secondObserver) + + val visibleRect: Rect = mockk(relaxed = true) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + + verify(exactly = 0) { firstObserver.visibleAreaChanged(any(), any()) } + verify(exactly = 0) { secondObserver.visibleAreaChanged(any(), any()) } + } + + @Test + fun `surfaceDestroyed should stop and destroy map before notifying observers`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + val testMapSurface = mockk(relaxed = true) + val mapboxCarMapSurface: MapboxCarMapSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns testMapSurface + } + + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + carMapSurfaceOwner.surfaceDestroyed() + + verifyOrder { + testMapSurface.onStop() + testMapSurface.surfaceDestroyed() + testMapSurface.onDestroy() + observer.detached(mapboxCarMapSurface) + } + } + + @Test + fun `should notify destroy and detached old surface when new surface is available`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + val firstMapSurface = mockk(relaxed = true) + val firstSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns firstMapSurface + } + val secondSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + } + + carMapSurfaceOwner.surfaceAvailable(firstSurface) + carMapSurfaceOwner.surfaceVisibleAreaChanged(mockk(relaxed = true)) + carMapSurfaceOwner.surfaceAvailable(secondSurface) + + verifyOrder { + observer.loaded(firstSurface) + observer.visibleAreaChanged(any(), any()) + observer.detached(firstSurface) + observer.loaded(secondSurface) + observer.visibleAreaChanged(any(), any()) + } + // Map style changes should not destroy the map. + verify(exactly = 0) { firstMapSurface.onStop() } + verify(exactly = 0) { firstMapSurface.surfaceDestroyed() } + verify(exactly = 0) { firstMapSurface.onDestroy() } + } + + @Test + fun `surfaceVisibleAreaChanged should notify visibleAreaChanged with edgeInsets`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + val visibleAreaSlot = slot() + val edgeInsets = slot() + every { + observer.visibleAreaChanged(capture(visibleAreaSlot), capture(edgeInsets)) + } just Runs + val mapboxCarMapSurface: MapboxCarMapSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + } + + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + val visibleRect = Rect(30, 112, 779, 381) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + + assertEquals(visibleRect, visibleAreaSlot.captured) + // edgeInset.left = visibleRect.left = 30 + assertEquals(30.0, edgeInsets.captured.left, 0.0001) + // edgeInset.top = visibleRect.top = 112 + assertEquals(112.0, edgeInsets.captured.top, 0.0001) + // edgeInset.right = surfaceContainer.width - visibleRect.right = 800 - 779 = 21 + assertEquals(21.0, edgeInsets.captured.right, 0.0001) + // edgeInsets.bottom = surfaceContainer.height - visibleRect.bottom = 400 - 381 = 19 + assertEquals(19.0, edgeInsets.captured.bottom, 0.0001) + } + + @Test + fun `visibleCenter is zero by default`() { + val visibleCenter = carMapSurfaceOwner.visibleCenter + assertEquals(0.0, visibleCenter.x, 0.0001) + assertEquals(0.0, visibleCenter.y, 0.0001) + } + + @Test + fun `surfaceAvailable finds the visibleCenter`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + val visibleAreaSlot = slot() + val edgeInsets = slot() + every { + observer.visibleAreaChanged(capture(visibleAreaSlot), capture(edgeInsets)) + } just Runs + val mapboxCarMapSurface: MapboxCarMapSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 805 + every { height } returns 405 + } + } + + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + val visibleCenter = carMapSurfaceOwner.visibleCenter + + assertEquals(402.5, visibleCenter.x, 0.0001) + assertEquals(202.5, visibleCenter.y, 0.0001) + } + + @Test + fun `surfaceVisibleAreaChanged finds the visibleCenter`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + val visibleAreaSlot = slot() + val edgeInsets = slot() + every { + observer.visibleAreaChanged(capture(visibleAreaSlot), capture(edgeInsets)) + } just Runs + val mapboxCarMapSurface: MapboxCarMapSurface = mockk { + every { surfaceContainer } returns mockk { + every { width } returns 805 + every { height } returns 405 + } + } + + carMapSurfaceOwner.surfaceAvailable(mapboxCarMapSurface) + val visibleRect = Rect(133, 112, 779, 381) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + + val visibleCenter = carMapSurfaceOwner.visibleCenter + assertEquals(456.0, visibleCenter.x, 0.0001) + assertEquals(246.5, visibleCenter.y, 0.0001) + } + + // This test may be deleted in the future, if we create or need a better way to detect + // start and end drag events. But for now, we're verifying that each scroll is completing a + // drag movement. + @Test + fun `scroll will start and stop dragging`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + val mapboxMap: MapboxMap = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val visibleRect = Rect(100, 50, 800, 400) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + + carMapSurfaceOwner.scroll(3.0f, -3.0f) + + verifyOrder { + mapboxMap.dragStart(ScreenCoordinate(450.0, 225.0)) + mapboxMap.setCamera(any()) + mapboxMap.dragEnd() + } + } + + @Test + fun `scroll will move camera from visibleCenter to the delta distance`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + val mapboxMap: MapboxMap = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val visibleRect = Rect(100, 50, 800, 400) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + val fromCoordinateSlot = slot() + val toCoordinateSlot = slot() + every { + mapboxMap.getDragCameraOptions(capture(fromCoordinateSlot), capture(toCoordinateSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scroll(3.3f, -3.3f) + + assertEquals(450.0, fromCoordinateSlot.captured.x, 0.0001) + assertEquals(225.0, fromCoordinateSlot.captured.y, 0.0001) + assertEquals(446.7, toCoordinateSlot.captured.x, 0.0001) + assertEquals(228.3, toCoordinateSlot.captured.y, 0.0001) + } + + @Test + fun `scroll is ignored if the observer returns true`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) { + every { scroll(any(), any(), any()) } returns true + } + val mapboxMap: MapboxMap = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val visibleRect = Rect(100, 50, 800, 400) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + val fromCoordinateSlot = slot() + val toCoordinateSlot = slot() + every { + mapboxMap.getDragCameraOptions(capture(fromCoordinateSlot), capture(toCoordinateSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scroll(3.3f, -3.3f) + + verify(exactly = 0) { mapboxMap.dragStart(any()) } + verify(exactly = 0) { mapboxMap.setCamera(any()) } + verify(exactly = 0) { mapboxMap.dragEnd() } + } + + @Test + fun `scale double-tap-gesture will easeTo new zoom`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + val cameraPlugin: CameraAnimationsPlugin = mockk(relaxed = true) + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mockk { + every { cameraState } returns mockk { + every { zoom } returns 10.0 + } + } + every { camera } returns cameraPlugin + } + } + ) + val visibleRect = Rect(100, 50, 800, 400) + carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) + val cameraOptionsSlot = slot() + every { + cameraPlugin.easeTo(capture(cameraOptionsSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scale(526.0f, 260.0f, 2.0f) + + with(cameraOptionsSlot.captured) { + assertEquals(526.0, anchor!!.x, 0.0001) + assertEquals(260.0, anchor!!.y, 0.0001) + assertEquals(11.0, zoom!!, 0.0001) + } + } + + @Test + fun `scale will use the focus as the anchor point`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) + val mapboxMap: MapboxMap = mockk(relaxed = true) { + every { cameraState } returns mockk { + every { zoom } returns 16.50 + } + } + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val cameraOptionsSlot = slot() + every { + mapboxMap.setCamera(capture(cameraOptionsSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scale(224.4f, 117.1f, 0.98f) + + with(cameraOptionsSlot.captured.anchor!!) { + assertEquals(224.4, x, 0.0001) + assertEquals(117.1, y, 0.0001) + } + } + + @Test + fun `scale with factor less than one will zoom out`() { + val expectedFromZoom = 16.50 + val expectedToZoom = 16.48 + val observer: MapboxCarMapObserver = mockk(relaxed = true) + val mapboxMap: MapboxMap = mockk(relaxed = true) { + every { cameraState } returns mockk { + every { zoom } returns expectedFromZoom + } + } + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val cameraOptionsSlot = slot() + every { + mapboxMap.setCamera(capture(cameraOptionsSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scale(400.0f, 200.0f, 0.98f) + + assertEquals(expectedToZoom, cameraOptionsSlot.captured.zoom!!, 0.0001) + } + + @Test + fun `scale with factor greater than one will zoom in`() { + val expectedFromZoom = 16.50 + val expectedToZoom = 16.52 + val observer: MapboxCarMapObserver = mockk(relaxed = true) + val mapboxMap: MapboxMap = mockk(relaxed = true) { + every { cameraState } returns mockk { + every { zoom } returns expectedFromZoom + } + } + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val cameraOptionsSlot = slot() + every { + mapboxMap.setCamera(capture(cameraOptionsSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scale(400.0f, 200.0f, 1.02f) + + assertEquals(expectedToZoom, cameraOptionsSlot.captured.zoom!!, 0.0001) + } + + @Test + fun `scale observer can set min and max thresholds`() { + val expectedFromZoom = 29.99 + val observer: MapboxCarMapObserver = mockk(relaxed = true) { + every { scale(any(), any(), any(), any()) } answers { + val toZoom: Double = arg(3) + assertEquals(29.99, arg(2), 0.001) + assertEquals(30.14, toZoom, 0.001) + toZoom >= 30 // Returning true makes 30 the maximum threshold + } + } + val mapboxMap: MapboxMap = mockk(relaxed = true) { + every { cameraState } returns mockk { + every { zoom } returns expectedFromZoom + } + } + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val cameraOptionsSlot = slot() + every { + mapboxMap.setCamera(capture(cameraOptionsSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scale(400.0f, 200.0f, 1.15f) + + verify(exactly = 0) { mapboxMap.setCamera(any()) } + } + + @Test + fun `scale is ignored if the observer returns true`() { + val observer: MapboxCarMapObserver = mockk(relaxed = true) { + every { scale(any(), any(), any(), any()) } returns true + } + val mapboxMap: MapboxMap = mockk(relaxed = true) { + every { cameraState } returns mockk { + every { zoom } returns 10.0 + } + } + carMapSurfaceOwner.registerObserver(observer) + carMapSurfaceOwner.surfaceAvailable( + mockk { + every { surfaceContainer } returns mockk { + every { width } returns 800 + every { height } returns 400 + } + every { mapSurface } returns mockk { + every { getMapboxMap() } returns mapboxMap + } + } + ) + val cameraOptionsSlot = slot() + every { + mapboxMap.setCamera(capture(cameraOptionsSlot)) + } returns mockk(relaxed = true) + + carMapSurfaceOwner.scale(400.0f, 200.0f, 1.15f) + + verify(exactly = 0) { mapboxMap.setCamera(any()) } + } +} \ No newline at end of file diff --git a/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/testing/ShadowLogger.java b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/testing/ShadowLogger.java new file mode 100644 index 0000000000..2e6103402b --- /dev/null +++ b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/testing/ShadowLogger.java @@ -0,0 +1,35 @@ +package com.mapbox.maps.extension.androidauto.testing; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.mapbox.common.Logger; + +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; + +@Implements(Logger.class) +public class ShadowLogger { + + @Implementation + public static void e(@Nullable String tag, @NonNull String message) { + Log.e(message, tag); + } + + @Implementation + public static void d(@Nullable String tag, @NonNull String message) { + Log.d(message, tag); + } + + @Implementation + public static void w(@Nullable String tag, @NonNull String message) { + Log.w(message, tag); + } + + @Implementation + public static void i(@Nullable String tag, @NonNull String message) { + Log.i(message, tag); + } +} \ No newline at end of file