diff --git a/app/src/main/java/org/groundplatform/android/repository/OfflineAreaRepository.kt b/app/src/main/java/org/groundplatform/android/repository/OfflineAreaRepository.kt index 8dfd1c98ab..9e93a1f5fc 100644 --- a/app/src/main/java/org/groundplatform/android/repository/OfflineAreaRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/OfflineAreaRepository.kt @@ -15,6 +15,8 @@ */ package org.groundplatform.android.repository +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException import javax.inject.Inject @@ -28,8 +30,6 @@ import org.groundplatform.android.data.uuid.OfflineUuidGenerator import org.groundplatform.android.system.GeocodingManager import org.groundplatform.android.ui.map.gms.mog.MogClient import org.groundplatform.android.ui.map.gms.mog.MogTileDownloader -import org.groundplatform.android.ui.map.gms.mog.getTilePath -import org.groundplatform.android.ui.util.FileUtil import org.groundplatform.android.util.deleteIfEmpty import org.groundplatform.android.util.rangeOf import org.groundplatform.domain.model.imagery.LocalTileSource @@ -47,8 +47,8 @@ private const val AREA_NAME_SENSITIVITY = 0.5 class OfflineAreaRepository @Inject constructor( + @ApplicationContext private val context: Context, private val localOfflineAreaStore: LocalOfflineAreaStore, - private val fileUtil: FileUtil, private val geocodingManager: GeocodingManager, private val mogClient: MogClient, private val offlineUuidGenerator: OfflineUuidGenerator, @@ -89,7 +89,7 @@ constructor( // TODO: Generate local tiles path based on source base path. // Issue URL: https://github.com/google/ground-android/issues/1730 - private fun getLocalTileDirectory(): File = File(fileUtil.getFilesDir(), "tiles") + private fun getLocalTileDirectory(): File = File(context.filesDir, "tiles") private fun getLocalTileSourcePath(): String = getLocalTileDirectory().path diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/ClippingTileProvider.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/ClippingTileProvider.kt index 380d9f8eaf..da60e2cabe 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/ClippingTileProvider.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/ClippingTileProvider.kt @@ -20,8 +20,8 @@ import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.Tile import com.google.android.gms.maps.model.TileProvider import com.google.android.gms.maps.model.TileProvider.NO_TILE -import org.groundplatform.android.ui.map.gms.mog.ImageEditor import org.groundplatform.android.ui.map.gms.mog.toPixelBounds +import org.groundplatform.android.util.image.TileImageTransformer import org.groundplatform.domain.model.imagery.TileCoordinates import org.groundplatform.domain.model.imagery.toPixelCoordinate @@ -45,7 +45,7 @@ class ClippingTileProvider( private fun clipToBounds(tileCoords: TileCoordinates, tile: Tile): Tile { if (tile.data == null) return NO_TILE val output = - ImageEditor.setTransparentIf(tile.data!!) { _, x, y -> + TileImageTransformer.setTransparentIf(tile.data!!) { _, x, y -> val pixelCoords = tileCoords.toPixelCoordinate(x, y) pixelBounds.none { it.contains(pixelCoords) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt index 0eae247ef4..56ede322fa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt @@ -48,7 +48,6 @@ import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.MapFragment import org.groundplatform.android.ui.map.gms.features.FeatureManager -import org.groundplatform.android.ui.map.gms.mog.MogSourceProvider.DEFAULT_MOG_MAX_ZOOM import org.groundplatform.android.ui.map.gms.mog.MogTileProvider import org.groundplatform.android.util.invert import org.groundplatform.android.util.systemInsets @@ -289,7 +288,7 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { } private fun addRemoteMogTileOverlay() { - val tileProvider = CachingUpscalingTileProvider(mogTileProvider, DEFAULT_MOG_MAX_ZOOM) + val tileProvider = CachingUpscalingTileProvider(mogTileProvider, mogTileProvider.maxZoom) addTileOverlay(tileProvider) } diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogClient.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogClient.kt index e8bbd417b5..e04b792283 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogClient.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogClient.kt @@ -36,6 +36,7 @@ import org.groundplatform.domain.model.imagery.MogCollection import org.groundplatform.domain.model.imagery.MogImageMetadata import org.groundplatform.domain.model.imagery.MogMetadata import org.groundplatform.domain.model.imagery.MogSource +import org.groundplatform.domain.model.imagery.MogTile import org.groundplatform.domain.model.imagery.MogTileMetadata import org.groundplatform.domain.model.imagery.MogTilesRequest import org.groundplatform.domain.model.imagery.TileCoordinates diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProvider.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProvider.kt index 8621f16fe6..24ec79bdf0 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProvider.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProvider.kt @@ -28,7 +28,7 @@ import org.groundplatform.domain.model.imagery.MogSource object MogSourceProvider { private const val DEFAULT_MOG_MIN_ZOOM = 8 - const val DEFAULT_MOG_MAX_ZOOM = 14 + private const val DEFAULT_MOG_MAX_ZOOM = 14 // TODO: Make sub-paths configurable and stop hardcoding here. // Issue URL: https://github.com/google/ground-android/issues/1730 diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTile.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTile.kt deleted file mode 100644 index bf18eccd70..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTile.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.groundplatform.android.ui.map.gms.mog - -import android.graphics.Color -import com.google.android.gms.maps.model.Tile -import org.groundplatform.domain.model.imagery.MogTileMetadata - -/** Metadata and image data for a single tile. */ -class MogTile(val metadata: MogTileMetadata, val data: ByteArray) { - private val jpegTablesData = - metadata.jpegTables - .drop(2) // Drop leading SOI. - .dropLast(2) // Drop trailing EOI. - .toByteArray() - - fun toGmsTile(): Tile { - val noData = metadata.noDataValue - val imageData = - if (noData == null) buildJfifFile() - else maskNoDataValues(buildJfifFile(), Color.rgb(noData, noData, noData)) - return Tile(metadata.width, metadata.height, imageData) - } - - private fun maskNoDataValues(jfifData: ByteArray, noDataColor: Int): ByteArray = - ImageEditor.setTransparentIf(jfifData) { bitmap, x, y -> bitmap.getPixel(x, y) == noDataColor } - - private fun buildJfifFile(): ByteArray = - START_OF_IMAGE + - app0Segment() + - jpegTablesData + - data.drop(2) + // Drop leading SOI. - END_OF_IMAGE - - /** Build "Application Segment 0" section of header. */ - private fun app0Segment() = - APP0_MARKER + - APP0_MIN_LEN.toByteArray() + - JFIF_IDENTIFIER.toNulTerminatedByteArray() + - JFIF_MAJOR_VERSION.toByte() + - JFIF_MINOR_VERSION.toByte() + - NO_DENSITY_UNITS.toByte() + - metadata.width.toShort().toByteArray() + - metadata.height.toShort().toByteArray() + - byteArrayOf(0, 0) // Dimensions of empty thumbnail. -} - -/* Circumference of the Earth (m) */ -private val START_OF_IMAGE = byteArrayOf(0xFF, 0xD8) -private val APP0_MARKER = byteArrayOf(0xFF, 0xE0) -// Marker segment length with no thumbnails. -private const val APP0_MIN_LEN: Short = 16 -private const val JFIF_IDENTIFIER = "JFIF" -private const val JFIF_MAJOR_VERSION = 1 -private const val JFIF_MINOR_VERSION = 2 -private const val NO_DENSITY_UNITS = 0 -private val END_OF_IMAGE = byteArrayOf(0xFF, 0xD9) - -private fun byteArrayOf(vararg elements: Int) = elements.map(Int::toByte).toByteArray() - -private fun Short.toByteArray() = byteArrayOf(this.toInt().shr(8).toByte(), this.toByte()) - -private fun String.toNulTerminatedByteArray() = this.toByteArray() + 0x00.toByte() diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileDownloader.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileDownloader.kt index 5f55477000..56de741ae2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileDownloader.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileDownloader.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.flow import org.groundplatform.domain.model.imagery.MogTilesRequest -import org.groundplatform.domain.model.imagery.TileCoordinates import timber.log.Timber /** @@ -43,8 +42,7 @@ class MogTileDownloader(private val client: MogClient, private val outputBasePat currentCoroutineContext().ensureActive() val outFile = File(outputBasePath, tile.metadata.tileCoordinates.getTilePath()) outFile.parentFile?.mkdirs() - val gmsTile = tile.toGmsTile() - val data = gmsTile.data!! + val data = tile.getProcessedImageData() outFile.writeBytes(data) Timber.d("Saved ${data.size} bytes to ${outFile.path}") emit(data.size) @@ -52,5 +50,3 @@ class MogTileDownloader(private val client: MogClient, private val outputBasePat } .cancellable() } - -fun TileCoordinates.getTilePath() = "$zoom/$x/$y.jpg" diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileExt.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileExt.kt new file mode 100644 index 0000000000..8ecc5453c6 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileExt.kt @@ -0,0 +1,37 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.android.ui.map.gms.mog + +import android.graphics.Color +import com.google.android.gms.maps.model.Tile +import org.groundplatform.android.util.image.TileImageTransformer +import org.groundplatform.domain.model.imagery.MogTile + +/** Returns a Maps SDK [Tile] instance for this [MogTile]. */ +fun MogTile.toGmsTile(): Tile = Tile(metadata.width, metadata.height, getProcessedImageData()) + +/** + * Returns the JFIF image data for this tile, with any pixels matching [metadata.noDataValue] masked + * as fully transparent. + */ +fun MogTile.getProcessedImageData(): ByteArray { + val noData = metadata.noDataValue ?: return buildJfifFile() + val noDataColor = Color.rgb(noData, noData, noData) + return TileImageTransformer.setTransparentIf(buildJfifFile()) { bitmap, x, y -> + bitmap.getPixel(x, y) == noDataColor + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileProvider.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileProvider.kt index 7824ce5be0..504a9b6b35 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileProvider.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileProvider.kt @@ -32,6 +32,10 @@ class MogTileProvider( private val coroutineDispatcher: CoroutineDispatcher, ) : TileProvider { + /** The maximum zoom level supported by the underlying MOG collection. */ + val maxZoom: Int + get() = client.collection.maxZoom + override fun getTile(x: Int, y: Int, zoom: Int): Tile? = runBlocking(coroutineDispatcher) { val tileCoordinates = TileCoordinates(x, y, zoom) diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileReader.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileReader.kt index 5b3f8e7e20..28e02225ff 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileReader.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/MogTileReader.kt @@ -19,6 +19,7 @@ package org.groundplatform.android.ui.map.gms.mog import java.io.InputStream import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import org.groundplatform.domain.model.imagery.MogTile import org.groundplatform.domain.model.imagery.MogTileMetadata import timber.log.Timber diff --git a/app/src/main/java/org/groundplatform/android/ui/util/FileUtil.kt b/app/src/main/java/org/groundplatform/android/ui/util/FileUtil.kt deleted file mode 100644 index cc0453b14f..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/util/FileUtil.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.android.ui.util - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject - -class FileUtil @Inject constructor(@ApplicationContext private val context: Context) { - - fun getFilesDir(): File = context.filesDir -} diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/ImageEditor.kt b/app/src/main/java/org/groundplatform/android/util/image/TileImageTransformer.kt similarity index 85% rename from app/src/main/java/org/groundplatform/android/ui/map/gms/mog/ImageEditor.kt rename to app/src/main/java/org/groundplatform/android/util/image/TileImageTransformer.kt index c85133d510..8c2a98c94a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/mog/ImageEditor.kt +++ b/app/src/main/java/org/groundplatform/android/util/image/TileImageTransformer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.groundplatform.android.ui.map.gms.mog +package org.groundplatform.android.util.image import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -22,11 +22,12 @@ import android.graphics.Color import android.os.Build import java.io.ByteArrayOutputStream -/** Utility for modifying images. */ -object ImageEditor { +/** Utility for transforming map tile images. */ +object TileImageTransformer { /** - * Decodes the provided source image, making all pixels for which the provided lambda returns - * `true` fully transparent (alpha = 0). Returns the modified images as a byte array. + * Decodes the provided source tile image, making all pixels for which the provided lambda returns + * `true` fully transparent (alpha = 0). Returns the modified tile image compressed as a WEBP byte + * array for Maps SDK compatibility. */ fun setTransparentIf( sourceImage: ByteArray, diff --git a/app/src/test/java/org/groundplatform/android/repository/OfflineAreaRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/OfflineAreaRepositoryTest.kt index 6c6e39433d..bab5072352 100644 --- a/app/src/test/java/org/groundplatform/android/repository/OfflineAreaRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/OfflineAreaRepositoryTest.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.android.repository +import android.content.Context import com.google.common.truth.Truth.assertThat import java.io.File import java.io.IOException @@ -26,8 +27,6 @@ import org.groundplatform.android.data.local.stores.LocalOfflineAreaStore import org.groundplatform.android.data.uuid.OfflineUuidGenerator import org.groundplatform.android.system.GeocodingManager import org.groundplatform.android.ui.map.gms.mog.MogClient -import org.groundplatform.android.ui.map.gms.mog.getTilePath -import org.groundplatform.android.ui.util.FileUtil import org.groundplatform.domain.model.imagery.LocalTileSource import org.groundplatform.domain.model.imagery.MogCollection import org.groundplatform.domain.model.imagery.MogSource @@ -56,8 +55,8 @@ class OfflineAreaRepositoryTest { @get:Rule val tempFolder: TemporaryFolder = TemporaryFolder() + @Mock private lateinit var context: Context @Mock private lateinit var localOfflineAreaStore: LocalOfflineAreaStore - @Mock private lateinit var fileUtil: FileUtil @Mock private lateinit var geocodingManager: GeocodingManager @Mock private lateinit var offlineUuidGenerator: OfflineUuidGenerator @@ -66,12 +65,12 @@ class OfflineAreaRepositoryTest { @Before fun setUp() { - whenever(fileUtil.getFilesDir()).thenReturn(tempFolder.root) + whenever(context.filesDir).thenReturn(tempFolder.root) mogClient = spy(MogClient(TEST_COLLECTION, mock())) repository = OfflineAreaRepository( + context, localOfflineAreaStore, - fileUtil, geocodingManager, mogClient, offlineUuidGenerator, diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogClientTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogClientTest.kt index 08a9db8d1b..0ee4b5a419 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogClientTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogClientTest.kt @@ -28,6 +28,7 @@ import org.groundplatform.domain.model.imagery.MogCollection import org.groundplatform.domain.model.imagery.MogImageMetadata import org.groundplatform.domain.model.imagery.MogMetadata import org.groundplatform.domain.model.imagery.MogSource +import org.groundplatform.domain.model.imagery.MogTile import org.groundplatform.domain.model.imagery.MogTileMetadata import org.groundplatform.domain.model.imagery.MogTilesRequest import org.groundplatform.domain.model.imagery.TileCoordinates diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProviderTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProviderTest.kt index 9bebf80f2c..3ad90f5a8c 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProviderTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogSourceProviderTest.kt @@ -16,6 +16,7 @@ package org.groundplatform.android.ui.map.gms.mog import com.google.common.truth.Truth.assertThat +import org.groundplatform.domain.model.imagery.maxZoom import org.junit.Test class MogSourceProviderTest { @@ -41,6 +42,6 @@ class MogSourceProviderTest { @Test fun defaultMogSources_maxZoomIsCorrect() { - assertThat(MogSourceProvider.DEFAULT_MOG_MAX_ZOOM).isEqualTo(14) + assertThat(MogSourceProvider.defaultMogSources.maxZoom()).isEqualTo(14) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogTileTest.kt b/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogTileExtTest.kt similarity index 85% rename from app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogTileTest.kt rename to app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogTileExtTest.kt index be603786d1..fbc66d2ff3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogTileTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/map/gms/mog/MogTileExtTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * 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. @@ -13,14 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.groundplatform.android.ui.map.gms.mog import com.google.common.truth.Truth.assertThat +import org.groundplatform.domain.model.imagery.MogTile import org.groundplatform.domain.model.imagery.MogTileMetadata import org.groundplatform.domain.model.imagery.TileCoordinates import org.junit.Test -class MogTileTest { +class MogTileExtTest { @Test fun `toGmsTile() returns valid tile`() { @@ -31,6 +33,12 @@ class MogTileTest { assertThat(gmsTile.data).isEqualTo(EXPECTED_DATA) } + @Test + fun `getProcessedImageData() returns valid JPEG data`() { + val mogTile = MogTile(MOG_TILE_METADATA, byteArrayOf(0, 1, 2, 3, 4)) + assertThat(mogTile.getProcessedImageData()).isEqualTo(EXPECTED_DATA) + } + companion object { private const val WIDTH = 100 private const val HEIGHT = 200 diff --git a/app/src/test/java/org/groundplatform/android/util/image/TileImageTransformerTest.kt b/app/src/test/java/org/groundplatform/android/util/image/TileImageTransformerTest.kt new file mode 100644 index 0000000000..e1582360ae --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/util/image/TileImageTransformerTest.kt @@ -0,0 +1,56 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.android.util.image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayOutputStream +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TileImageTransformerTest { + + @Test + fun `setTransparentIf makes matching pixels transparent`() { + val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + bitmap.setPixel(0, 0, Color.RED) + bitmap.setPixel(1, 0, Color.GREEN) + bitmap.setPixel(0, 1, Color.BLUE) + bitmap.setPixel(1, 1, Color.WHITE) + + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + val inputBytes = outputStream.toByteArray() + + val resultBytes = + TileImageTransformer.setTransparentIf(inputBytes) { bmp, x, y -> + bmp.getPixel(x, y) == Color.RED + } + + val resultBitmap = BitmapFactory.decodeByteArray(resultBytes, 0, resultBytes.size) + assertThat(resultBitmap).isNotNull() + assertThat(resultBitmap.width).isEqualTo(2) + assertThat(resultBitmap.height).isEqualTo(2) + + assertThat(Color.alpha(resultBitmap.getPixel(0, 0))).isEqualTo(0) + assertThat(Color.alpha(resultBitmap.getPixel(1, 0))).isGreaterThan(0) + } +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogCollection.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogCollection.kt index e82da330cf..a78060832c 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogCollection.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogCollection.kt @@ -18,5 +18,14 @@ package org.groundplatform.domain.model.imagery /** A collection of Maps Optimized GeoTIFFs (MOGs). */ class MogCollection(val sources: List) { + val minZoom: Int + get() = sources.minZoom() + + val maxZoom: Int + get() = sources.maxZoom() + + val zoomRange: IntRange + get() = sources.zoomRange() + fun getMogSource(zoom: Int) = sources.firstOrNull { it.zoomRange.contains(zoom) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogTile.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogTile.kt new file mode 100644 index 0000000000..146687a93f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/MogTile.kt @@ -0,0 +1,63 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.domain.model.imagery + +/** Metadata and raw image data for a single tile. */ +class MogTile(val metadata: MogTileMetadata, val data: ByteArray) { + private val jpegTablesData = + metadata.jpegTables + .drop(2) // Drop leading SOI. + .dropLast(2) // Drop trailing EOI. + .toByteArray() + + /** Combines JPEG tables and image data into a valid standalone JFIF (JPEG) byte array. */ + fun buildJfifFile(): ByteArray = + START_OF_IMAGE + + app0Segment() + + jpegTablesData + + data.drop(2) + // Drop leading SOI. + END_OF_IMAGE + + /** Build "Application Segment 0" section of header. */ + private fun app0Segment() = + APP0_MARKER + + APP0_MIN_LEN.toByteArray() + + JFIF_IDENTIFIER.encodeToByteArray() + + 0x00.toByte() + + JFIF_MAJOR_VERSION.toByte() + + JFIF_MINOR_VERSION.toByte() + + NO_DENSITY_UNITS.toByte() + + metadata.width.toShort().toByteArray() + + metadata.height.toShort().toByteArray() + + byteArrayOf(0, 0) // Dimensions of empty thumbnail. + + companion object { + private val START_OF_IMAGE = byteArrayOf(0xFF, 0xD8) + private val APP0_MARKER = byteArrayOf(0xFF, 0xE0) + // Marker segment length with no thumbnails. + private const val APP0_MIN_LEN: Short = 16 + private const val JFIF_IDENTIFIER = "JFIF" + private const val JFIF_MAJOR_VERSION = 1 + private const val JFIF_MINOR_VERSION = 2 + private const val NO_DENSITY_UNITS = 0 + private val END_OF_IMAGE = byteArrayOf(0xFF, 0xD9) + + private fun byteArrayOf(vararg elements: Int) = elements.map(Int::toByte).toByteArray() + + private fun Short.toByteArray() = byteArrayOf(this.toInt().shr(8).toByte(), this.toByte()) + } +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/TileCoordinates.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/TileCoordinates.kt index a475bdac6c..245acde653 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/TileCoordinates.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/imagery/TileCoordinates.kt @@ -34,6 +34,9 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) { override fun toString(): String = "($x, $y) at zoom $zoom" + /** Returns the relative path for the tile file in the format `{zoom}/{x}/{y}.jpg`. */ + fun getTilePath(): String = "$zoom/$x/$y.jpg" + companion object { /** * Returns the coordinates of the tile at a particular zoom containing the specified latitude diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogCollectionTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogCollectionTest.kt index 80d9172092..96fc4b260f 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogCollectionTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogCollectionTest.kt @@ -55,4 +55,19 @@ class MogCollectionTest { val mogSource = mogCollection.getMogSource(15) assertNull(mogSource) } + + @Test + fun `minZoom returns minimum zoom across all sources`() { + assertEquals(0, mogCollection.minZoom) + } + + @Test + fun `maxZoom returns maximum zoom across all sources`() { + assertEquals(14, mogCollection.maxZoom) + } + + @Test + fun `zoomRange returns full zoom range across all sources`() { + assertEquals(0..14, mogCollection.zoomRange) + } } diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogTileTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogTileTest.kt new file mode 100644 index 0000000000..cb8a8d54a3 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/MogTileTest.kt @@ -0,0 +1,71 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.domain.model.imagery + +import kotlin.test.Test +import kotlin.test.assertTrue + +class MogTileTest { + + @Test + fun `buildJfifFile() returns valid JFIF bytes`() { + val mogTile = MogTile(MOG_TILE_METADATA, byteArrayOf(0, 1, 2, 3, 4)) + assertTrue(mogTile.buildJfifFile().contentEquals(EXPECTED_DATA)) + } + + companion object { + private const val WIDTH = 100 + private const val HEIGHT = 200 + private val MOG_TILE_METADATA = + MogTileMetadata( + tileCoordinates = TileCoordinates(0, 0, 0), + width = WIDTH, + height = HEIGHT, + jpegTables = byteArrayOf(100, 101, 102, 103, 104), + byteRange = LongRange(0, 10), + ) + private val EXPECTED_DATA = + byteArrayOf( + -1, + -40, + -1, + -32, + 0, + 16, + 74, + 70, + 73, + 70, + 0, + 1, + 2, + 0, + 0, + 100, + 0, + -56, + 0, + 0, + 102, + 2, + 3, + 4, + -1, + -39, + ) + } +} diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/TileCoordinatesTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/TileCoordinatesTest.kt index 6ab76bff2d..68e9ef2db5 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/TileCoordinatesTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/model/imagery/TileCoordinatesTest.kt @@ -35,6 +35,11 @@ class TileCoordinatesTest { assertEquals("(10, 20) at zoom 10", TileCoordinates(10, 20, 10).toString()) } + @Test + fun `getTilePath returns formatted path`() { + assertEquals("10/100/200.jpg", TileCoordinates(100, 200, 10).getTilePath()) + } + @Test fun `returns tiles within bounds correctly`() { val tiles = TileCoordinates.withinBounds(Bounds(10.0, 10.0, 11.0, 11.0), 10)