diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt new file mode 100644 index 00000000..17ba8390 --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/data/DataLayer.kt @@ -0,0 +1,297 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.data + +import android.graphics.Bitmap +import androidx.annotation.RawRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.MapEffect +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.rememberReattachClickListenersHandle +import com.google.maps.android.data.DataLayerLoader +import com.google.maps.android.data.renderer.IconProvider +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import com.google.maps.android.data.renderer.model.DataLayer +import com.google.maps.android.data.renderer.model.DataScene +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withContext + +/** + * Renders a platform-agnostic [DataScene] onto the map using [MapViewRenderer]. + * + * When [scene] changes or the composable leaves composition, previously rendered features are + * removed from the map. + * + * @param scene the [DataScene] to render. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataScene( + scene: DataScene, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, +) { + val reattach = rememberReattachClickListenersHandle() + + MapEffect( + iconProvider, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + scene, + useAdvancedMarkers, + ) { map -> + val renderer = MapViewRenderer( + map = map, + iconProvider = iconProvider, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ).apply { + this.useAdvancedMarkers = useAdvancedMarkers + } + renderSceneWithCachedImages(renderer, scene) + if ( + markerManager != null || + polygonManager != null || + polylineManager != null || + groundOverlayManager != null + ) { + reattach() + } + try { + awaitCancellation() + } finally { + renderer.clear() + } + } +} + +/** + * Renders a platform-agnostic [DataLayer] (parsed from GeoJSON, KML, KMZ, or GPX) onto the map + * using [MapViewRenderer]. + * + * @param layer the [DataLayer] to render. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataLayer( + layer: DataLayer, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, +) { + val scene = remember(layer) { DataScene(listOf(layer)) } + DataScene( + scene = scene, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) +} + +/** + * Loads a [DataLayer] from an application asset file (supporting `.geojson`, `.json`, `.kml`, + * `.kmz`, and `.gpx`) using [DataLayerLoader] and renders it onto the map. + * + * @param assetName the path of the asset file in the application's `assets/` directory. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used for reading and parsing the asset file. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onError an optional callback invoked with any [Throwable] encountered during loading or parsing. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataLayer( + assetName: String, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onError: ((Throwable) -> Unit)? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + val onErrorState by rememberUpdatedState(onError) + var loadedLayer by remember { mutableStateOf(null) } + + LaunchedEffect(context, assetName, ioDispatcher) { + val result = runCatching { + DataLayerLoader(context, ioDispatcher).loadAsset(assetName) + } + val layer = result.getOrNull() + if (layer != null) { + loadedLayer = layer + onLayerLoadedState(layer) + } else { + val error = result.exceptionOrNull() + ?: IllegalStateException("Failed to load DataLayer from asset: $assetName") + onErrorState?.invoke(error) + onLayerLoadedState(null) + } + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +/** + * Loads a [DataLayer] from a raw Android resource (supporting GeoJSON, KML, KMZ, and GPX) using + * [DataLayerLoader] and renders it onto the map. + * + * @param rawResourceId the `@RawRes` resource identifier to load. + * @param fileName an optional file name hint (e.g., `"boroughs.geojson"` or `"campus.kmz"`) used by + * [DataLayerLoader] for extension matching before falling back to content sniffing. + * @param iconProvider the [IconProvider] used to fetch remote marker or ground overlay icons. + * @param useAdvancedMarkers whether to render point features using Advanced Markers. + * @param ioDispatcher the [CoroutineDispatcher] used for reading and parsing the raw resource. + * @param markerManager an optional [MarkerManager] to manage rendered markers. + * @param polygonManager an optional [PolygonManager] to manage rendered polygons. + * @param polylineManager an optional [PolylineManager] to manage rendered polylines. + * @param groundOverlayManager an optional [GroundOverlayManager] to manage rendered ground overlays. + * @param onError an optional callback invoked with any [Throwable] encountered during loading or parsing. + * @param onLayerLoaded an optional callback invoked with the parsed [DataLayer] (or `null` if + * parsing failed). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun DataLayer( + @RawRes rawResourceId: Int, + fileName: String? = null, + iconProvider: IconProvider = remember { UrlIconProvider() }, + useAdvancedMarkers: Boolean = false, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + onError: ((Throwable) -> Unit)? = null, + onLayerLoaded: (DataLayer?) -> Unit = {}, +) { + val context = LocalContext.current + val resources = LocalResources.current + val onLayerLoadedState by rememberUpdatedState(onLayerLoaded) + val onErrorState by rememberUpdatedState(onError) + var loadedLayer by remember { mutableStateOf(null) } + + LaunchedEffect(context, resources, rawResourceId, fileName, ioDispatcher) { + val result = withContext(ioDispatcher) { + runCatching { + resources.openRawResource(rawResourceId).use { stream -> + DataLayerLoader(context, ioDispatcher).loadInputStream(stream, fileName) + } + } + } + val layer = result.getOrNull() + if (layer != null) { + loadedLayer = layer + onLayerLoadedState(layer) + } else { + val error = result.exceptionOrNull() + ?: IllegalStateException("Failed to load DataLayer from raw resource: $rawResourceId") + onErrorState?.invoke(error) + onLayerLoadedState(null) + } + } + + loadedLayer?.let { layer -> + DataLayer( + layer = layer, + iconProvider = iconProvider, + useAdvancedMarkers = useAdvancedMarkers, + markerManager = markerManager, + polygonManager = polygonManager, + polylineManager = polylineManager, + groundOverlayManager = groundOverlayManager, + ) + } +} + +internal fun renderSceneWithCachedImages(renderer: MapViewRenderer, scene: DataScene) { + scene.layers.forEach { layer -> + val images = layer.properties["images"] as? Map<*, *> + images?.forEach { (key, value) -> + if (key is String && value is Bitmap) { + renderer.cacheImageData(key, value) + } + } + } + renderer.render(scene) +} diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt new file mode 100644 index 00000000..786e8d84 --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlay.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.heatmaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.TileOverlay +import com.google.maps.android.compose.GoogleMapComposable +import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.TileOverlay +import com.google.maps.android.compose.TileOverlayState +import com.google.maps.android.compose.rememberTileOverlayState +import com.google.maps.android.heatmaps.Gradient +import com.google.maps.android.heatmaps.HeatmapTileProvider +import com.google.maps.android.heatmaps.WeightedLatLng + +/** + * A Composable that displays a heatmap tile overlay on the map from a collection of unweighted + * [LatLng] coordinates. + * + * If [data] is empty, no tile overlay is rendered until points are provided. + * + * @param data the collection of [LatLng] points to display in the heatmap. + * @param state the [TileOverlayState] used to control the tile overlay. + * @param radius the radius of the Gaussian blur in pixels, between [HeatmapTileProvider.MIN_RADIUS] + * and [HeatmapTileProvider.MAX_RADIUS]. + * @param gradient the color [Gradient] of the heatmap. + * @param opacity the opacity of the heatmap layer in the range `[0.0, 1.0]`. + * @param maxIntensity the custom maximum intensity value for the heatmap (`0.0` uses the default + * auto-computed maximum). + * @param fadeIn boolean indicating whether the tiles should fade in. + * @param transparency the transparency of the tile overlay. + * @param visible the visibility of the tile overlay. + * @param zIndex the z-index of the tile overlay. + * @param onClick a lambda invoked when the tile overlay is clicked. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun HeatmapTileOverlay( + data: Collection, + state: TileOverlayState = rememberTileOverlayState(), + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0, + fadeIn: Boolean = true, + transparency: Float = 0f, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: (TileOverlay) -> Unit = {}, +) { + val weightedData = remember(data) { + data.map { WeightedLatLng(it) } + } + HeatmapTileOverlay( + weightedData = weightedData, + state = state, + radius = radius, + gradient = gradient, + opacity = opacity, + maxIntensity = maxIntensity, + fadeIn = fadeIn, + transparency = transparency, + visible = visible, + zIndex = zIndex, + onClick = onClick, + ) +} + +/** + * A Composable that displays a heatmap tile overlay on the map from a collection of + * [WeightedLatLng] points. + * + * If [weightedData] is empty, no tile overlay is rendered until points are provided. + * + * @param weightedData the collection of [WeightedLatLng] points to display in the heatmap. + * @param state the [TileOverlayState] used to control the tile overlay. + * @param radius the radius of the Gaussian blur in pixels, between [HeatmapTileProvider.MIN_RADIUS] + * and [HeatmapTileProvider.MAX_RADIUS]. + * @param gradient the color [Gradient] of the heatmap. + * @param opacity the opacity of the heatmap layer in the range `[0.0, 1.0]`. + * @param maxIntensity the custom maximum intensity value for the heatmap (`0.0` uses the default + * auto-computed maximum). + * @param fadeIn boolean indicating whether the tiles should fade in. + * @param transparency the transparency of the tile overlay. + * @param visible the visibility of the tile overlay. + * @param zIndex the z-index of the tile overlay. + * @param onClick a lambda invoked when the tile overlay is clicked. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +@JvmName("WeightedHeatmapTileOverlay") +public fun HeatmapTileOverlay( + weightedData: Collection, + state: TileOverlayState = rememberTileOverlayState(), + radius: Int = HeatmapTileProvider.DEFAULT_RADIUS, + gradient: Gradient = HeatmapTileProvider.DEFAULT_GRADIENT, + opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY, + maxIntensity: Double = 0.0, + fadeIn: Boolean = true, + transparency: Float = 0f, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: (TileOverlay) -> Unit = {}, +) { + if (weightedData.isEmpty()) return + + val provider = remember { + HeatmapTileProvider.Builder() + .weightedData(weightedData) + .radius(radius) + .gradient(gradient) + .opacity(opacity) + .maxIntensity(maxIntensity) + .build() + } + + val currentParams = remember { + HeatmapParams( + radius = radius, + opacity = opacity, + maxIntensity = maxIntensity, + gradient = gradient, + weightedData = weightedData, + ) + } + + SideEffect { + var changed = false + if (currentParams.radius != radius) { + provider.setRadius(radius) + currentParams.radius = radius + changed = true + } + if (currentParams.opacity != opacity) { + provider.setOpacity(opacity) + currentParams.opacity = opacity + changed = true + } + if (currentParams.maxIntensity != maxIntensity) { + provider.setMaxIntensity(maxIntensity) + currentParams.maxIntensity = maxIntensity + changed = true + } + if (!gradientsEqual(currentParams.gradient, gradient)) { + provider.setGradient(gradient) + currentParams.gradient = gradient + changed = true + } + if (currentParams.weightedData != weightedData) { + provider.updateData(weightedData) + currentParams.weightedData = weightedData + changed = true + } + if (changed) { + runCatching { state.clearTileCache() } + } + } + + TileOverlay( + tileProvider = provider, + state = state, + fadeIn = fadeIn, + transparency = transparency, + visible = visible, + zIndex = zIndex, + onClick = onClick, + ) +} + +private class HeatmapParams( + var radius: Int, + var opacity: Double, + var maxIntensity: Double, + var gradient: Gradient, + var weightedData: Collection, +) + +internal fun gradientsEqual(g1: Gradient?, g2: Gradient?): Boolean { + if (g1 === g2) return true + if (g1 == null || g2 == null) return false + return g1.colorMapSize == g2.colorMapSize && + g1.colors.contentEquals(g2.colors) && + g1.startPoints.contentEquals(g2.startPoints) +} diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt new file mode 100644 index 00000000..0c82b001 --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/data/DataLayerTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.data + +import android.content.Context +import android.graphics.Bitmap +import com.google.maps.android.data.DataLayerLoader +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import com.google.maps.android.data.renderer.model.DataLayer +import com.google.maps.android.data.renderer.model.DataScene +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.ByteArrayInputStream + +@OptIn(ExperimentalCoroutinesApi::class) +public class DataLayerTest { + + @Test + public fun testRenderSceneWithCachedImagesCachesBitmapsBeforeRender() { + val renderer = mockk(relaxed = true) + val bitmap = mockk() + every { renderer.cacheImageData(any(), any()) } just runs + every { renderer.render(any()) } just runs + + val layer = DataLayer( + features = emptyList(), + properties = mapOf("images" to mapOf("icons/pin.png" to bitmap)), + ) + val scene = DataScene(listOf(layer)) + + renderSceneWithCachedImages(renderer, scene) + + verify { renderer.cacheImageData("icons/pin.png", bitmap) } + verify { renderer.render(scene) } + } + + @Test + public fun testDataLayerLoaderParsesGeoJsonAndKmlFromInputStream() { + runTest { + val context = mockk(relaxed = true) + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val loader = DataLayerLoader(context, dispatcher) + + val validGeoJson = """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "feature-1", + "geometry": { + "type": "Point", + "coordinates": [-122.084, 37.422] + }, + "properties": { + "title": "Googleplex" + } + } + ] + } + """.trimIndent() + + val geoJsonLayer = loader.loadInputStream( + inputStream = ByteArrayInputStream(validGeoJson.toByteArray()), + fileName = "points.geojson", + ) + assertNotNull(geoJsonLayer) + assertEquals(1, geoJsonLayer!!.features.size) + assertEquals("Googleplex", geoJsonLayer.features.first().properties["title"]) + + val validKml = """ + + + + + Mountain View + + -122.084,37.422,0 + + + + + """.trimIndent() + + val kmlLayer = loader.loadInputStream( + inputStream = ByteArrayInputStream(validKml.toByteArray()), + fileName = "places.kml", + ) + assertNotNull(kmlLayer) + assertEquals(1, kmlLayer!!.features.size) + assertEquals("Mountain View", kmlLayer.features.first().properties["name"]) + + val invalidLayer = loader.loadInputStream( + inputStream = ByteArrayInputStream("not valid data {{{".toByteArray()), + fileName = "unknown.txt", + ) + assertNull(invalidLayer) + } + } +} diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt new file mode 100644 index 00000000..b5c596a2 --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/heatmaps/HeatmapTileOverlayTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.heatmaps + +import com.google.maps.android.heatmaps.Gradient +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +public class HeatmapTileOverlayTest { + + @Test + public fun testGradientsEqual() { + val g1 = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 1000, + ) + val g2 = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 1000, + ) + val gDifferentColors = Gradient( + intArrayOf(0xFF0000FF.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 1000, + ) + val gDifferentPoints = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.5f, 1.0f), + 1000, + ) + val gDifferentSize = Gradient( + intArrayOf(0xFF00FF00.toInt(), 0xFFFF0000.toInt()), + floatArrayOf(0.2f, 1.0f), + 500, + ) + + assertTrue(gradientsEqual(g1, g1)) + assertTrue(gradientsEqual(g1, g2)) + assertTrue(gradientsEqual(null, null)) + + assertFalse(gradientsEqual(g1, null)) + assertFalse(gradientsEqual(null, g1)) + assertFalse(gradientsEqual(g1, gDifferentColors)) + assertFalse(gradientsEqual(g1, gDifferentPoints)) + assertFalse(gradientsEqual(g1, gDifferentSize)) + } +}