diff --git a/android-auto-app/build.gradle.kts b/android-auto-app/build.gradle.kts index ff2a7d217b..f19fa58d25 100644 --- a/android-auto-app/build.gradle.kts +++ b/android-auto-app/build.gradle.kts @@ -8,11 +8,11 @@ plugins { val buildFromSource: String by project android { - compileSdkVersion(AndroidVersions.compileSdkVersion) + compileSdkVersion(AndroidVersions.compileSdkVersion_AndroidAuto) defaultConfig { applicationId = "com.mapbox.maps.testapp.auto" - minSdkVersion(AndroidVersions.minAndroidAutoSdkVersion) - targetSdkVersion(AndroidVersions.targetSdkVersion) + minSdkVersion(AndroidVersions.minSdkVersion_AndroidAuto) + targetSdkVersion(AndroidVersions.targetSdkVersion_AndroidAuto) versionCode = 1 versionName = "0.1.0" multiDexEnabled = true @@ -50,10 +50,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..ece2e7aaec 100644 --- a/android-auto-app/src/main/AndroidManifest.xml +++ b/android-auto-app/src/main/AndroidManifest.xml @@ -8,7 +8,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 onAttached(mapboxCarMapSurface: MapboxCarMapSurface) { + super.onAttached(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 onDetached(mapboxCarMapSurface: MapboxCarMapSurface) { + previousCameraState = mapboxCarMapSurface.mapSurface.getMapboxMap().cameraState + with(mapboxCarMapSurface.mapSurface.location) { + removeOnIndicatorPositionChangedListener(changePositionListener) } + super.onDetached(mapboxCarMapSurface) + } + + override fun onVisibleAreaChanged(visibleArea: Rect, edgeInsets: EdgeInsets) { + insets = edgeInsets } - override fun onMapScroll() { + override fun onScroll( + 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 +96,51 @@ 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 { + /** + * Default location for the demo. + */ private val HELSINKI = Point.fromLngLat(24.9384, 60.1699) + + /** + * Default zoom for the demo. + */ private const val INITIAL_ZOOM = 16.0 + + /** + * Constant camera pitch for the demo. + */ private const val INITIAL_PITCH = 75.0 - private const val TAG = "CarCameraController" + + /** + * When zooming the camera by a delta, this will prevent the camera from zooming further. + */ + private const val MIN_ZOOM_OUT = 6.0 + + /** + * When zooming the camera by a delta, this will prevent the camera from zooming further. + */ + 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..2a04f81b1b --- /dev/null +++ b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/car/CarMapShowcase.kt @@ -0,0 +1,63 @@ +package com.mapbox.maps.testapp.auto.car + +import androidx.car.app.CarContext +import com.mapbox.maps.MapboxExperimental +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.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 + +/** + * Example showing how you can add a sky layer that has a sun direction, + * and adding a terrain layer to show mountains. + */ +@OptIn(MapboxExperimental::class) +class CarMapShowcase : MapboxCarMapObserver { + + private var mapboxCarMapSurface: MapboxCarMapSurface? = null + + override fun onAttached(mapboxCarMapSurface: MapboxCarMapSurface) { + this.mapboxCarMapSurface = mapboxCarMapSurface + loadMapStyle(mapboxCarMapSurface.carContext) + } + + override fun onDetached(mapboxCarMapSurface: MapboxCarMapSurface) { + this.mapboxCarMapSurface = null + } + + fun mapStyleUri(carContext: CarContext): String { + return if (carContext.isDarkMode) Style.TRAFFIC_NIGHT else Style.TRAFFIC_DAY + } + + fun loadMapStyle(carContext: CarContext) { + // 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. + val sunDirection = if (carContext.isDarkMode) listOf(-50.0, 90.2) else listOf(0.0, 0.0) + + mapboxCarMapSurface?.mapSurface?.getMapboxMap()?.loadStyle( + styleExtension = style(mapStyleUri(carContext)) { + +rasterDemSource(DEM_SOURCE) { + url(TERRAIN_URL_TILE_RESOURCE) + tileSize(514) + } + +terrain(DEM_SOURCE) + +skyLayer(SKY_LAYER) { + skyType(SkyType.ATMOSPHERE) + skyAtmosphereSun(sunDirection) + } + } + ) + } + + 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..7a07587cc0 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 @@ -1,32 +1,33 @@ package com.mapbox.maps.testapp.auto.car -import androidx.car.app.CarContext import androidx.car.app.CarToast import androidx.car.app.Screen -import androidx.car.app.model.* +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.Template 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.MapboxExperimental +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) { +@OptIn(MapboxExperimental::class) +class MapScreen( + val mapboxCarMap: MapboxCarMap +) : Screen(mapboxCarMap.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() - builder.setBackgroundColor(CarColor.SECONDARY) + .setBackgroundColor(CarColor.SECONDARY) builder.setActionStrip( ActionStrip.Builder() @@ -41,7 +42,7 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { ).build() ) .setOnClickListener { - carCameraController.get()?.focusOnLocationPuck() + carCameraController.focusOnLocationPuck() } .build() ) @@ -76,7 +77,7 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { ) .setOnClickListener { // handle zoom out - carCameraController.get()?.zoomBy(0.95) + carCameraController.zoomOutAction() } .build() ) @@ -91,7 +92,7 @@ class MapScreen(carContext: CarContext) : Screen(carContext) { ).build() ) .setOnClickListener { - carCameraController.get()?.zoomBy(1.05) + carCameraController.zoomInAction() } .build() ) @@ -114,4 +115,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..c6fa7c05bc 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 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 androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapboxExperimental +import com.mapbox.maps.extension.androidauto.MapboxCarMap /** * Session class for the Mapbox Map sample app for Android Auto. */ +@OptIn(MapboxExperimental::class) class MapSession : Session() { - private lateinit var mapSurface: MapSurface - private val carCameraController = CarCameraController() + private val carMapShowcase = CarMapShowcase() + private var mapboxCarMap: MapboxCarMap? = null 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) - } + // The onCreate is guaranteed to be called before onCreateScreen. You can pass the + // mapboxCarMap to other screens. Each screen can register and unregister observers. + // This allows you to scope behaviors to sessions, screens, or events. + val mapScreen = MapScreen(mapboxCarMap!!) + return if (carContext.checkSelfPermission(ACCESS_FINE_LOCATION) != PERMISSION_GRANTED) { - carContext.getCarService(ScreenManager::class.java).push(mapScreen) + 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) - } - } - override fun onCarConfigurationChanged(newConfiguration: Configuration) { - super.onCarConfigurationChanged(newConfiguration) - loadStyle(mapSurface) + carMapShowcase.loadMapStyle(carContext) } - 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) { + // The carContext is not initialized until onCreate. Initialize your object here + // and then register any observers that should have a lifecycle for the entire + // car session. + mapboxCarMap = MapboxCarMap(MapInitOptions(carContext)) + .registerObserver(carMapShowcase) + } + + override fun onDestroy(owner: LifecycleOwner) { + mapboxCarMap?.clearObservers() + mapboxCarMap = null + } + }) } } \ No newline at end of file diff --git a/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/testing/CarJavaInterfaceChecker.java b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/testing/CarJavaInterfaceChecker.java new file mode 100644 index 0000000000..6ba0aa214b --- /dev/null +++ b/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto/testing/CarJavaInterfaceChecker.java @@ -0,0 +1,74 @@ +package com.mapbox.maps.testapp.auto.testing; + +import android.graphics.Rect; + +import androidx.annotation.NonNull; +import androidx.car.app.CarContext; +import androidx.car.app.SurfaceContainer; + +import com.mapbox.maps.EdgeInsets; +import com.mapbox.maps.MapInitOptions; +import com.mapbox.maps.MapSurface; +import com.mapbox.maps.ScreenCoordinate; +import com.mapbox.maps.extension.androidauto.MapboxCarMap; +import com.mapbox.maps.extension.androidauto.MapboxCarMapObserver; +import com.mapbox.maps.extension.androidauto.MapboxCarMapSurface; + +class CarJavaInterfaceChecker { + + void constructors(MapInitOptions mapInitOptions) { + MapboxCarMap mapboxCarMap = new MapboxCarMap(mapInitOptions); + } + + void getters(MapboxCarMap mapboxCarMap) { + CarContext carContext = mapboxCarMap.getCarContext(); + Rect visibleArea = mapboxCarMap.getVisibleArea(); + EdgeInsets edgeInsets = mapboxCarMap.getEdgeInsets(); + MapboxCarMapSurface mapboxCarMapSurface = mapboxCarMap.getCarMapSurface(); + } + + void getters(MapboxCarMapSurface mapboxCarMapSurface) { + CarContext carContext = mapboxCarMapSurface.getCarContext(); + MapSurface mapSurface = mapboxCarMapSurface.getMapSurface(); + SurfaceContainer surfaceContainer = mapboxCarMapSurface.getSurfaceContainer(); + } + + private void observers(MapboxCarMap mapboxCarMap) { + MapboxCarMapObserver observer = new MapboxCarMapObserver() { + + @Override + public boolean onScale(@NonNull MapboxCarMapSurface mapboxCarMapSurface, @NonNull ScreenCoordinate anchor, double fromZoom, double toZoom) { + return false; + } + + @Override + public boolean onFling(@NonNull MapboxCarMapSurface mapboxCarMapSurface, float velocityX, float velocityY) { + return false; + } + + @Override + public boolean onScroll(@NonNull MapboxCarMapSurface mapboxCarMapSurface, float distanceX, float distanceY) { + return false; + } + + @Override + public void onVisibleAreaChanged(@NonNull Rect visibleArea, @NonNull EdgeInsets edgeInsets) { + + } + + @Override + public void onDetached(@NonNull MapboxCarMapSurface mapboxCarMapSurface) { + + } + + @Override + public void onAttached(@NonNull MapboxCarMapSurface mapboxCarMapSurface) { + + } + }; + mapboxCarMap.registerObserver(observer); + mapboxCarMap.unregisterObserver(observer); + mapboxCarMap.clearObservers(); + } + +} 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/buildSrc/src/main/kotlin/Project.kt b/buildSrc/src/main/kotlin/Project.kt index 2476879a47..e3c82a0c3e 100644 --- a/buildSrc/src/main/kotlin/Project.kt +++ b/buildSrc/src/main/kotlin/Project.kt @@ -1,8 +1,10 @@ object AndroidVersions { const val minSdkVersion = 21 - const val minAndroidAutoSdkVersion = 23 const val targetSdkVersion = 30 const val compileSdkVersion = 30 + const val minSdkVersion_AndroidAuto = 23 + const val targetSdkVersion_AndroidAuto = 30 + const val compileSdkVersion_AndroidAuto = 31 } object Plugins { @@ -99,7 +101,7 @@ object Versions { const val squareLeakCanary = "2.4" const val materialDesign = "1.2.0" const val googlePlayServicesLocation = "18.0.0" - const val googleCarAppLibrary= "1.1.0-alpha01" + const val googleCarAppLibrary= "1.1.0" const val kotlinCoroutines = "1.3.9" const val junit = "4.13" const val mockk = "1.9.3" diff --git a/extension-androidauto/README.md b/extension-androidauto/README.md index 303e25f376..05e565a100 100644 --- a/extension-androidauto/README.md +++ b/extension-androidauto/README.md @@ -1,14 +1,16 @@ -## Mapbox Maps Android Auto Extension for Android +# Mapbox Maps Android Auto Extension -### Overview +## Overview -The Mapbox Maps Android Auto Extension for Android is an public library for initialising the MapSurface from a [car app session](https://developer.android.com/reference/androidx/car/app/Session), and handling scroll and scale gestures. +The Mapbox Maps Android Auto Extension is a public library for initialising the Mapbox `MapSurface` from a [Session](https://developer.android.com/reference/androidx/car/app/Session). The extension provides a simple framework for adding Mapbox to Android Auto head units. A full overview of classes and interfaces can be found in our [API documentation](https://docs.mapbox.com/android/beta/maps/guides/). -### Getting Started +Working examples of the Android Auto extension can be found in our [test application](https://github.com/mapbox/mapbox-maps-android/tree/master/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto). To see examples of an integration with the Mapbox Navigation and Search SDKs, you can also try the [navigation examples](https://github.com/mapbox/mapbox-navigation-android-examples). -This README is intended for developers who are interested in [contributing](https://github.com/mapbox/mapbox-maps-android/blob/master/CONTRIBUTING.md) to the Mapbox Maps Android Auto Extension for Android. Please visit [DEVELOPING.md](https://github.com/mapbox/mapbox-maps-android/blob/master/DEVELOPING.md) for general information and instructions on how to use the Mapbox Maps Extension System. To add the android auto extension to your project, you configure its dependency in your `build.gradle` files. +## Getting Started + +This README is intended for developers who are interested in [contributing](https://github.com/mapbox/mapbox-maps-android/blob/master/CONTRIBUTING.md) or building an app that uses the Mapbox Maps Android Auto Extension. Please visit [DEVELOPING.md](https://github.com/mapbox/mapbox-maps-android/blob/master/DEVELOPING.md) for general information and instructions on how to use the Mapbox Maps Extension System. To add the android auto extension to your project, you configure its dependency in your `build.gradle` files. ```groovy // In the root build.gradle file @@ -28,39 +30,106 @@ allprojects { } } -// In the app build.gradle file +// In your build.gradle, add the extension with your other dependencies. dependencies { - implementation 'com.mapbox.extension:maps-androidauto:10.0.0-rc.9' + implementation 'com.mapbox.extension:maps-androidauto:10.4.+' + + // Pick your versions of mapbox map and android auto. + implementation 'androidx.car.app:app:1.+' + implementation 'com.mapbox.maps:android:10.4.+' } ``` -### Example +## AndroidManifest and permissions -Using the Mapbox Maps Android Auto Extension for Android could be done using: +You should become familiar with [Google's documentation for building a navigation app](https://developer.android.com/training/cars/apps/navigation). You will need to declare your own [CarAppService](https://developer.android.com/reference/androidx/car/app/CarAppService), your own `minCarApiLevel`, create your own `xml/automotive_app_desc`, and make your own car app theme. -```kotlin -class MapSession : Session() { - private lateinit var mapSurface: MapSurface +The Mapbox Android Auto extension includes the `androidx.car.app.ACCESS_SURFACE` permission, you do not need to add this permission to your manifest. You will need to add the `androidx.car.app.NAVIGATION_TEMPLATES` permission, along with any other permission your app requires. + +``` xml + + + + + + + > + + + + + + + + + + + + + + + + + + + + +``` + +## Example Session and MapScreen +In this example, the Session manages an instance of `MapboxCarMap` and then each `Screen` can register and unregister observers. Each screen can control map features. Your implementations of `MapboxCarMapObserver` give you control of the map. + +```kotlin +class MySession : Session() { override fun onCreateScreen(intent: Intent): Screen { - val mapScreen = MapScreen(carContext) - initMapSurface(scrollListener = carCameraController) { surface -> - // Interact with the created MapSurface - mapSurface = surface - surface.getMapboxMap().loadStyleUri(Style.MAPBOX_STREETS) { style -> - // Interact with the style - ... + // You must create the MapInitOptions with the CarContext. + val mapInitOptions = MapInitOptions(carContext) + val mapboxCarMap = MapboxCarMap(mapInitOptions) + return MapScreen(mapboxCarMap) + } +} + +class MyMapScreen( + val mapboxCarMap: MapboxCarMap +) : Screen(mapboxCarMap.carContext) { + + override fun onGetTemplate(): Template { + val builder = NavigationTemplate.Builder() + --snip-- + + init { + val myCustomMapExperience = MyCustomMapExperience() + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + mapboxCarMap.registerObserver(myCustomMapExperience) } - } - return if (carContext.checkSelfPermission(ACCESS_FINE_LOCATION) != PERMISSION_GRANTED) { - carContext.getCarService(ScreenManager::class.java).push(mapScreen) - RequestPermissionScreen(carContext) - } else mapScreen + override fun onDestroy(owner: LifecycleOwner) { + mapboxCarMap.unregisterObserver(myCustomMapExperience) + } + }) } -``` -More concrete examples of the Android Auto extension can be found in our [test application](https://github.com/mapbox/mapbox-maps-android/tree/master/android-auto-app/src/main/java/com/mapbox/maps/testapp/auto). +class MyCustomMapExperience : MapboxCarMapObserver { + override fun onAttached(mapboxCarMapSurface: MapboxCarMapSurface) { + val mapboxMap = mapboxCarMapSurface?.mapSurface?.getMapboxMap()!! + mapboxMap.loadStyle( + --snip-- +} +``` #### Dependencies diff --git a/extension-androidauto/build.gradle.kts b/extension-androidauto/build.gradle.kts index 9a21f38f60..8e069daa3f 100644 --- a/extension-androidauto/build.gradle.kts +++ b/extension-androidauto/build.gradle.kts @@ -7,10 +7,10 @@ plugins { } android { - compileSdkVersion(AndroidVersions.compileSdkVersion) + compileSdkVersion(AndroidVersions.compileSdkVersion_AndroidAuto) defaultConfig { - minSdkVersion(AndroidVersions.minAndroidAutoSdkVersion) - targetSdkVersion(AndroidVersions.targetSdkVersion) + minSdkVersion(AndroidVersions.minSdkVersion_AndroidAuto) + targetSdkVersion(AndroidVersions.targetSdkVersion_AndroidAuto) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -23,10 +23,13 @@ android { dependencies { compileOnly(project(":sdk")) + testImplementation(project(":sdk")) + implementation(Dependencies.googleCarAppLibrary) implementation(Dependencies.kotlin) implementation(Dependencies.androidxCoreKtx) implementation(Dependencies.androidxAnnotations) + testImplementation(Dependencies.junit) testImplementation(Dependencies.mockk) testImplementation(Dependencies.androidxTestCore) @@ -50,6 +53,6 @@ project.apply { from("$rootDir/gradle/ktlint.gradle") from("$rootDir/gradle/lint.gradle") from("$rootDir/gradle/jacoco.gradle") -// from("$rootDir/gradle/sdk-registry.gradle") + from("$rootDir/gradle/sdk-registry.gradle") from("$rootDir/gradle/track-public-apis.gradle") } \ No newline at end of file diff --git a/extension-androidauto/src/main/AndroidManifest.xml b/extension-androidauto/src/main/AndroidManifest.xml index 582dc0a176..9dbed1a4f5 100644 --- a/extension-androidauto/src/main/AndroidManifest.xml +++ b/extension-androidauto/src/main/AndroidManifest.xml @@ -1 +1,6 @@ - + + + + + diff --git a/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceCallback.kt b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceCallback.kt new file mode 100644 index 0000000000..da845f30ee --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceCallback.kt @@ -0,0 +1,78 @@ +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 com.mapbox.common.Logger +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapboxExperimental + +/** + * @see MapboxCarMap to create new map experiences. + * + * This is a Mapbox implementation for the [SurfaceCallback]. It is used to simplify the lower + * level calls that manage the map surface. This class handles the surface callbacks and forwards + * them to the [CarMapSurfaceOwner] where [MapboxCarMapObserver] instances are notified. + */ +@OptIn(MapboxExperimental::class) +internal class CarMapSurfaceCallback internal constructor( + private val carContext: CarContext, + private val carMapSurfaceOwner: CarMapSurfaceOwner, + private val mapInitOptions: MapInitOptions +) : SurfaceCallback { + + fun onBind() { + carContext.getCarService(AppManager::class.java).setSurfaceCallback(this) + } + + 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.surfaceChanged(surfaceContainer.width, surfaceContainer.height) + val carMapSurface = MapboxCarMapSurface(carContext, mapSurface, surfaceContainer) + carMapSurfaceOwner.surfaceAvailable(carMapSurface) + } + } + + 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. + } + + 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() + } + + private companion object { + private const val TAG = "CarMapSurfaceCallback" + } +} \ 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..6ce417beed --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwner.kt @@ -0,0 +1,192 @@ +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.MapboxExperimental +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]. + */ +@OptIn(MapboxExperimental::class) +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.onAttached(carMapSurface) + } + ifNonNull(mapboxCarMapSurface, visibleArea, edgeInsets) { _, area, edge -> + Logger.i(TAG, "registerObserver visibleAreaChanged") + mapboxCarMapObserver.onVisibleAreaChanged(area, edge) + } + } + + fun unregisterObserver(mapboxCarMapObserver: MapboxCarMapObserver) { + carMapObservers.remove(mapboxCarMapObserver) + mapboxCarMapSurface?.let { mapboxCarMapObserver.onDetached(it) } + Logger.i(TAG, "unregisterObserver - 1 = ${carMapObservers.size}") + } + + fun clearObservers() { + this.mapboxCarMapSurface?.let { surface -> carMapObservers.forEach { it.onDetached(surface) } } + carMapObservers.clear() + } + + fun surfaceAvailable(mapboxCarMapSurface: MapboxCarMapSurface) { + Logger.i(TAG, "surfaceAvailable") + val oldCarMapSurface = this.mapboxCarMapSurface + this.mapboxCarMapSurface = mapboxCarMapSurface + oldCarMapSurface?.let { carMapObservers.forEach { it.onDetached(oldCarMapSurface) } } + carMapObservers.forEach { it.onAttached(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.onDetached(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.onVisibleAreaChanged(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.onScroll(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.onFling(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.onScale(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..5abf70f529 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMap.kt @@ -0,0 +1,102 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.lifecycle.Lifecycle +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapboxExperimental + +/** + * 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. + * + * The internals of this class use [AppManager.setSurfaceCallback], which assumes there is a single + * surface callback. Do not use setSurfaceCallback, and do not create multiple instances of + * [MapboxCarMap]. + * + * @param mapInitOptions to initialize the [MapboxCarMapSurface] + */ +@MapboxExperimental +class MapboxCarMap( + private val mapInitOptions: MapInitOptions +) { + /** + * Accessor for the carContext provided to the MapInitOptions. This makes it easier to create + * screens with the MapboxCarMap in the constructor. + * + * For example: + * class YourMapScreen(val mapboxCarMap: MapboxCarMap) : Screen(mapboxCarMap.carContext) { + * + * The carContext can also be found in the [MapboxCarMapObserver] callbacks. Make sure to + * call [MapboxCarMap.clearObservers] when your car session is destroyed. + */ + val carContext: CarContext = mapInitOptions.context as? CarContext + ?: throw IllegalArgumentException("You must construct a MapboxCarMap with the CarContext") + + private val carMapSurfaceOwner = CarMapSurfaceOwner() + private val carMapSurfaceCallback = CarMapSurfaceCallback( + carContext, + carMapSurfaceOwner, + mapInitOptions + ) + + init { + carMapSurfaceCallback.onBind() + } + + /** + * Returns the current [MapboxCarMapSurface]. It is recommended to use [registerObserver] and + * [MapboxCarMapObserver] to attach and detach your customizations. + */ + val carMapSurface: MapboxCarMapSurface? + get() { return carMapSurfaceOwner.mapboxCarMapSurface } + + /** + * Accessor to the visible area calculated by the car library. It is recommended to + * use the values returned by [MapboxCarMapObserver.onVisibleAreaChanged]. + */ + val visibleArea: Rect? + get() { return carMapSurfaceOwner.visibleArea } + + /** + * Accessor to the edgeInsets calculated by the car library. It is recommended to + * use the values returned by [MapboxCarMapObserver.onVisibleAreaChanged]. + */ + val edgeInsets: EdgeInsets? + get() { return carMapSurfaceOwner.edgeInsets } + + /** + * @param mapboxCarMapObserver implements the desired mapbox car experiences + */ + fun registerObserver(mapboxCarMapObserver: MapboxCarMapObserver) = apply { + carMapSurfaceOwner.registerObserver(mapboxCarMapObserver) + } + + /** + * @param mapboxCarMapObserver the instance used in [registerObserver] + */ + fun unregisterObserver(mapboxCarMapObserver: MapboxCarMapObserver) { + carMapSurfaceOwner.unregisterObserver(mapboxCarMapObserver) + } + + /** + * Optional function to clear all observers registered through [registerObserver] + */ + fun clearObservers() { + carMapSurfaceOwner.clearObservers() + } +} \ 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..94ae421ac0 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapObserver.kt @@ -0,0 +1,120 @@ +package com.mapbox.maps.extension.androidauto + +import android.graphics.Rect +import androidx.car.app.SurfaceCallback +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapboxExperimental +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. + */ +@MapboxExperimental +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 onAttached(mapboxCarMapSurface: MapboxCarMapSurface) { + // No op by default + } + + /** + * Called when a [MapboxCarMapSurface] has been detached from this observer. Some examples that + * can cause this to detach are: + * - [MapboxCarMap] lifecycle is destroyed + * - This observer has been unregistered with [MapboxCarMap.unregisterObserver] + * + * You can assume that there was a corresponding call to [onAttached] with the same + * [MapboxCarMapObserver] instance. + * + * @param mapboxCarMapSurface loaded and ready car map surface + */ + fun onDetached(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 [onAttached]. + * + * @param visibleArea the visible area provided by the host + * @param edgeInsets distance from each side of the screen that creates the [visibleArea] + */ + fun onVisibleAreaChanged(visibleArea: Rect, edgeInsets: EdgeInsets) { + // No op by default + } + + /** + * Allows you to implement or observe the map scroll gesture handler. The surface is [onAttached] + * 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 onScroll( + 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 [onAttached] + * 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 onFling( + 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 [onAttached] + * 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 onScale( + mapboxCarMapSurface: MapboxCarMapSurface, + anchor: ScreenCoordinate, + fromZoom: Double, + toZoom: Double + ): Boolean { + // By default, scale is handled internally + return false + } +} \ 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..5606f25c73 --- /dev/null +++ b/extension-androidauto/src/main/java/com/mapbox/maps/extension/androidauto/MapboxCarMapSurface.kt @@ -0,0 +1,33 @@ +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.MapboxExperimental + +/** + * 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. + */ +@MapboxExperimental +class MapboxCarMapSurface internal constructor( + val carContext: CarContext, + val mapSurface: MapSurface, + val surfaceContainer: SurfaceContainer, +) { + /** + * Get a string representation of the map surface. + * + * @return the string representation + */ + override fun toString(): String { + return "MapboxCarMapSurface(carContext=$carContext," + + " mapSurface=$mapSurface," + + " surfaceContainer=$surfaceContainer" + + ")" + } +} \ 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/CarMapSurfaceCallbackTest.kt b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceCallbackTest.kt new file mode 100644 index 0000000000..fef99043f1 --- /dev/null +++ b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceCallbackTest.kt @@ -0,0 +1,118 @@ +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 com.mapbox.common.Logger +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapSurface +import com.mapbox.maps.MapboxExperimental +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 + +@OptIn(MapboxExperimental::class) +class CarMapSurfaceCallbackTest { + + 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 carMapSurfaceCallback = CarMapSurfaceCallback( + 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 surfaceCallback = slot() + every { carContext.getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallback)) } just Runs + } + + carMapSurfaceCallback.onBind() + + assertTrue(surfaceCallback.isCaptured) + assertEquals(surfaceCallback.captured, carMapSurfaceCallback) + } + + @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 + + carMapSurfaceCallback.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 + + carMapSurfaceCallback.onVisibleAreaChanged(visibleRect) + + verify(exactly = 1) { carMapSurfaceOwner.surfaceVisibleAreaChanged(visibleRect) } + } + + @Test + fun `onStableAreaChanged should not do anything`() { + carMapSurfaceCallback.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 + + carMapSurfaceCallback.onSurfaceDestroyed(mockk()) + + verify(exactly = 1) { carMapSurfaceOwner.surfaceDestroyed() } + } +} \ 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..9443fb3081 --- /dev/null +++ b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/CarMapSurfaceOwnerTest.kt @@ -0,0 +1,620 @@ +@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.MapboxExperimental +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.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(MapboxExperimental::class) +@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.onAttached(any()) } + verify(exactly = 0) { secondObserver.onAttached(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.onDetached(any()) } + verify(exactly = 0) { secondObserver.onDetached(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.onAttached(mapboxCarMapSurface) } + verify(exactly = 1) { secondObserver.onAttached(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.onVisibleAreaChanged(any(), any()) } + verify(exactly = 0) { secondObserver.onVisibleAreaChanged(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.onVisibleAreaChanged(any(), any()) } + verify(exactly = 1) { secondObserver.onVisibleAreaChanged(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.onVisibleAreaChanged(any(), any()) } + verify(exactly = 0) { secondObserver.onVisibleAreaChanged(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.onDetached(mapboxCarMapSurface) + } + } + + @Test + fun `onDetached is called after mapboxCarMapSurface becomes null when surfaceDestroyed`() { + carMapSurfaceOwner.registerObserver(object : MapboxCarMapObserver { + override fun onDetached(mapboxCarMapSurface: MapboxCarMapSurface) { + assertNull(carMapSurfaceOwner.mapboxCarMapSurface) + } + }) + + 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() + } + + @Test + fun `onDetached is called after mapboxCarMapSurface becomes null when new surfaceAvailable`() { + carMapSurfaceOwner.registerObserver(object : MapboxCarMapObserver { + override fun onDetached(mapboxCarMapSurface: MapboxCarMapSurface) { + assertNotEquals(carMapSurfaceOwner.mapboxCarMapSurface, mapboxCarMapSurface) + } + }) + + 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) + } + + @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.onAttached(firstSurface) + observer.onVisibleAreaChanged(any(), any()) + observer.onDetached(firstSurface) + observer.onAttached(secondSurface) + observer.onVisibleAreaChanged(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.onVisibleAreaChanged(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.onVisibleAreaChanged(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.onVisibleAreaChanged(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 { onScroll(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 { onScale(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 { onScale(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/MapboxCarMapTest.kt b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/MapboxCarMapTest.kt new file mode 100644 index 0000000000..a8c16fe448 --- /dev/null +++ b/extension-androidauto/src/test/java/com/mapbox/maps/extension/androidauto/MapboxCarMapTest.kt @@ -0,0 +1,220 @@ +package com.mapbox.maps.extension.androidauto + +import androidx.car.app.AppManager +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import com.mapbox.common.Logger +import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapSurface +import com.mapbox.maps.MapboxExperimental +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.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(MapboxExperimental::class) +class MapboxCarMapTest { + + private val testMapSurface: MapSurface = mockk(relaxed = true) + + @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(expected = RuntimeException::class) + fun `MapboxCarMap constructor crashes when context is not a CarContext`() { + val mapInitOptions = mockk { + every { context } returns mockk() + } + + MapboxCarMap(mapInitOptions) + } + + @Test + fun `MapboxCarMap constructor requests the surface callback`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + MapboxCarMap(mapInitOptions) + + assertTrue(surfaceCallbackSlot.isCaptured) + } + + @Test + fun `carMapSurface is valid after onSurfaceAvailable`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + assertNull(mapboxCarMap.carMapSurface) + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + + assertNotNull(mapboxCarMap.carMapSurface) + } + + @Test + fun `visibleArea is valid after onVisibleAreaChanged`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + assertNull(mapboxCarMap.visibleArea) + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + surfaceCallbackSlot.captured.onVisibleAreaChanged(mockk(relaxed = true)) + + assertNotNull(mapboxCarMap.visibleArea) + } + + @Test + fun `edgeInsets is valid after onVisibleAreaChanged`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + assertNull(mapboxCarMap.edgeInsets) + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + surfaceCallbackSlot.captured.onVisibleAreaChanged(mockk(relaxed = true)) + + assertNotNull(mapboxCarMap.edgeInsets) + } + + @Test + fun `registerObserver includes onAttached and onVisibleAreaChanged`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + val observer = mockk(relaxed = true) + mapboxCarMap.registerObserver(observer) + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + surfaceCallbackSlot.captured.onVisibleAreaChanged(mockk(relaxed = true)) + + verifyOrder { + observer.onAttached(any()) + observer.onVisibleAreaChanged(any(), any()) + } + } + + @Test + fun `registerObserver receives callbacks if registered after onVisibleAreaChanged`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + surfaceCallbackSlot.captured.onVisibleAreaChanged(mockk(relaxed = true)) + + val observer = mockk(relaxed = true) + mapboxCarMap.registerObserver(observer) + + verifyOrder { + observer.onAttached(any()) + observer.onVisibleAreaChanged(any(), any()) + } + } + + @Test + fun `unregisterObserver will prevent callbacks`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + val observer = mockk(relaxed = true) + mapboxCarMap.registerObserver(observer) + mapboxCarMap.unregisterObserver(observer) + + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + surfaceCallbackSlot.captured.onVisibleAreaChanged(mockk(relaxed = true)) + + verify(exactly = 0) { observer.onAttached(any()) } + verify(exactly = 0) { observer.onVisibleAreaChanged(any(), any()) } + verify(exactly = 0) { observer.onDetached(any()) } + } + + @Test + fun `clearObservers will prevent callbacks`() { + val surfaceCallbackSlot = slot() + val mapInitOptions = mockk { + every { context } returns mockk { + every { getCarService(AppManager::class.java) } returns mockk { + every { setSurfaceCallback(capture(surfaceCallbackSlot)) } just Runs + } + } + } + + val mapboxCarMap = MapboxCarMap(mapInitOptions) + val observer = mockk(relaxed = true) + mapboxCarMap.registerObserver(observer) + mapboxCarMap.clearObservers() + + surfaceCallbackSlot.captured.onSurfaceAvailable(mockk(relaxed = true)) + surfaceCallbackSlot.captured.onVisibleAreaChanged(mockk(relaxed = true)) + + verify(exactly = 0) { observer.onAttached(any()) } + verify(exactly = 0) { observer.onVisibleAreaChanged(any(), any()) } + verify(exactly = 0) { observer.onDetached(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..46be4f9f4b --- /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