diff --git a/ground/build.gradle b/ground/build.gradle index d638132107..75e81895be 100644 --- a/ground/build.gradle +++ b/ground/build.gradle @@ -32,7 +32,7 @@ project.ext { fragmentVersion = "1.5.7" hiltJetpackVersion = "1.0.0" lifecycleVersion = "2.6.1" - roomVersion = "2.5.1" + roomVersion = "2.5.2" rxBindingVersion = "2.2.0" workVersion = "2.8.1" mockitoVersion = "4.5.1" @@ -251,9 +251,6 @@ dependencies { implementation "com.uber.autodispose:autodispose-android:$project.autoDisposeVersion" implementation "com.uber.autodispose:autodispose-android-archcomponents:$project.autoDisposeVersion" - // Apache Commons IO - implementation 'commons-io:commons-io:2.8.0' - // Testing testImplementation 'junit:junit:4.13.2' androidTestImplementation 'junit:junit:4.13.2' diff --git a/ground/src/main/java/com/google/android/ground/Config.kt b/ground/src/main/java/com/google/android/ground/Config.kt index 1b99c1cac8..bdb80a9473 100644 --- a/ground/src/main/java/com/google/android/ground/Config.kt +++ b/ground/src/main/java/com/google/android/ground/Config.kt @@ -26,7 +26,7 @@ object Config { // Local db settings. // TODO(#128): Reset version to 1 before releasing. - const val DB_VERSION = 104 + const val DB_VERSION = 106 const val DB_NAME = "ground.db" // Firebase Cloud Firestore settings. diff --git a/ground/src/main/java/com/google/android/ground/model/imagery/TileSource.kt b/ground/src/main/java/com/google/android/ground/model/imagery/TileSource.kt index 69ce4a16cd..cd428136b1 100644 --- a/ground/src/main/java/com/google/android/ground/model/imagery/TileSource.kt +++ b/ground/src/main/java/com/google/android/ground/model/imagery/TileSource.kt @@ -15,18 +15,14 @@ */ package com.google.android.ground.model.imagery -import org.apache.commons.io.FilenameUtils +import com.google.android.ground.ui.map.Bounds /** Represents a single source of tiled map imagery. */ -data class TileSource(val url: String, val type: Type) { +data class TileSource(val url: String, val type: Type, val clipBounds: List = listOf()) { + // TODO(#1790): Consider creating specialized classes for each type of TileSource. enum class Type { TILED_WEB_MAP, MOG_COLLECTION, UNKNOWN, } - - companion object { - fun fromFileExtension(url: String) = - if (FilenameUtils.getExtension(url) == "png") Type.TILED_WEB_MAP else Type.MOG_COLLECTION - } } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/OfflineAreaDao.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/OfflineAreaDao.kt index ad59948302..0c6866ff81 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/OfflineAreaDao.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/dao/OfflineAreaDao.kt @@ -24,9 +24,8 @@ import io.reactivex.Maybe /** Provides read/write operations for writing [OfflineAreaEntity] to the local db. */ @Dao interface OfflineAreaDao : BaseDao { - @Query("SELECT * FROM offline_base_map") - fun findAllOnceAndStream(): Flowable> + @Query("SELECT * FROM offline_area") fun findAllOnceAndStream(): Flowable> - @Query("SELECT * FROM offline_base_map WHERE id = :id") + @Query("SELECT * FROM offline_area WHERE id = :id") fun findById(id: String): Maybe } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OfflineAreaEntity.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OfflineAreaEntity.kt index a9996cec01..2dcd23199b 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OfflineAreaEntity.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/entity/OfflineAreaEntity.kt @@ -22,7 +22,7 @@ import com.google.android.ground.model.imagery.OfflineArea import com.google.android.ground.persistence.local.room.fields.OfflineAreaEntityState /** Represents a [OfflineArea] in the local data store. */ -@Entity(tableName = "offline_base_map") +@Entity(tableName = "offline_area") data class OfflineAreaEntity( @ColumnInfo(name = "id") @PrimaryKey val id: String, @ColumnInfo(name = "name") val name: String, diff --git a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SurveyConverter.kt b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SurveyConverter.kt index 84293dda49..77224e7223 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SurveyConverter.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/remote/firebase/schema/SurveyConverter.kt @@ -58,6 +58,6 @@ internal object SurveyConverter { private fun convertTileSources(pd: SurveyDocument, builder: MutableList) { pd.tileSources ?.mapNotNull { it.url } - ?.forEach { url -> builder.add(TileSource(url, TileSource.fromFileExtension(url))) } + ?.forEach { url -> builder.add(TileSource(url, TileSource.Type.MOG_COLLECTION)) } } } diff --git a/ground/src/main/java/com/google/android/ground/repository/MapStateRepository.kt b/ground/src/main/java/com/google/android/ground/repository/MapStateRepository.kt index f9e370d62a..8e053a370f 100644 --- a/ground/src/main/java/com/google/android/ground/repository/MapStateRepository.kt +++ b/ground/src/main/java/com/google/android/ground/repository/MapStateRepository.kt @@ -30,7 +30,7 @@ class MapStateRepository @Inject constructor(private val localValueStore: LocalV val mapTypeFlowable: Flowable by localValueStore::mapTypeFlowable var mapType: MapType by localValueStore::mapType var isLocationLockEnabled: Boolean by localValueStore::isLocationLockEnabled - val offlineImageryFlow: Flow by localValueStore::offlineImageryEnabledFlow + val offlineImageryEnabledFlow: Flow by localValueStore::offlineImageryEnabledFlow var isOfflineImageryEnabled: Boolean by localValueStore::isOfflineImageryEnabled fun setCameraPosition(cameraPosition: CameraPosition) = diff --git a/ground/src/main/java/com/google/android/ground/repository/OfflineAreaRepository.kt b/ground/src/main/java/com/google/android/ground/repository/OfflineAreaRepository.kt index 93993e28ed..cb6322e2e8 100644 --- a/ground/src/main/java/com/google/android/ground/repository/OfflineAreaRepository.kt +++ b/ground/src/main/java/com/google/android/ground/repository/OfflineAreaRepository.kt @@ -17,6 +17,7 @@ package com.google.android.ground.repository import com.google.android.ground.Config import com.google.android.ground.model.imagery.OfflineArea +import com.google.android.ground.model.imagery.TileSource import com.google.android.ground.persistence.local.stores.LocalOfflineAreaStore import com.google.android.ground.persistence.uuid.OfflineUuidGenerator import com.google.android.ground.rx.annotations.Cold @@ -30,6 +31,7 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.* +import kotlinx.coroutines.reactive.asFlow /** * Corners of the viewport are scaled by this value when determining the name of downloaded areas. @@ -94,7 +96,29 @@ constructor( } // TODO(#1730): Generate local tiles path based on source base path. - fun getLocalTileSourcePath(): String = File(fileUtil.filesDir.path, "tiles").path + private fun getLocalTileSourcePath(): String = File(fileUtil.filesDir.path, "tiles").path + + fun getOfflineTileSourcesFlow(): Flow> = + surveyRepository.activeSurveyFlow + // TODO(#1593): Use Room DAO's Flow once we figure out why it never emits a value. + .combine(getOfflineAreaBounds().asFlow()) { survey, bounds -> + applyBounds(survey?.tileSources, bounds) + } + + private fun applyBounds(tileSources: List?, bounds: List): List = + tileSources?.mapNotNull { tileSource -> toOfflineTileSource(tileSource, bounds) } ?: listOf() + + private fun toOfflineTileSource(tileSource: TileSource, clipBounds: List): TileSource? { + if (tileSource.type != TileSource.Type.MOG_COLLECTION) return null + return TileSource( + "file://${getLocalTileSourcePath()}/{z}/{x}/{y}.jpg", + TileSource.Type.TILED_WEB_MAP, + clipBounds + ) + } + + private fun getOfflineAreaBounds(): Flowable> = + localOfflineAreaStore.offlineAreasOnceAndStream().map { list -> list.map { it.bounds } } /** * Uses the first tile source URL of the currently active survey and returns a [MogClient], or diff --git a/ground/src/main/java/com/google/android/ground/ui/common/AbstractMapContainerFragment.kt b/ground/src/main/java/com/google/android/ground/ui/common/AbstractMapContainerFragment.kt index 352545a183..aae56f0fde 100644 --- a/ground/src/main/java/com/google/android/ground/ui/common/AbstractMapContainerFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/common/AbstractMapContainerFragment.kt @@ -15,7 +15,6 @@ */ package com.google.android.ground.ui.common -import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.widget.Toast @@ -46,6 +45,8 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { } private fun onMapAttached(map: Map) { + val viewModel = getMapViewModel() + // Removes all markers, overlays, polylines and polygons from the map. map.clear() @@ -56,17 +57,18 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { map.startDragEvents .onBackpressureLatest() .`as`(RxAutoDispose.disposeOnDestroy(this)) - .subscribe { getMapViewModel().onMapDragged() } + .subscribe { viewModel.onMapDragged() } lifecycleScope.launch { getMapViewModel().locationLock.collect { onLocationLockStateChange(it, map) } } + viewModel.mapType.observe(viewLifecycleOwner) { map.mapType = it } lifecycleScope.launch { - getMapViewModel().getCameraUpdates().collect { onCameraUpdateRequest(it, map) } + viewModel.getCameraUpdates().collect { onCameraUpdateRequest(it, map) } } - // Enable map controls - getMapViewModel().setLocationLockEnabled(true) + // Enable map controls. + viewModel.setLocationLockEnabled(true) applyMapConfig(map) onMapReady(map) @@ -82,26 +84,15 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { getMapViewModel().mapType.observe(viewLifecycleOwner) { map.mapType = it } } - // Offline imagery - if (config.showTileOverlays) { - lifecycleScope.launch { - getMapViewModel().offlineImageryEnabled.collect { enabled -> - if (enabled) addTileOverlays() else map.clearTileOverlays() - } + // Tile overlays. + if (getMapConfig().showOfflineTileOverlays) { + getMapViewModel().offlineTileSources.observe(viewLifecycleOwner) { + map.clearTileOverlays() + it.forEach(map::addTileOverlay) } } } - @SuppressLint("FragmentLiveDataObserve") - private fun addTileOverlays() { - // TODO(#1756): Clear tile overlays on change to stop accumulating them on map. - - // TODO(#1782): Changing the owner to `viewLifecycleOwner` in observe() causes a crash in task - // fragment and converting live data to flow results in clear tiles not working. Figure out a - // better way to fix the IDE warning. - getMapViewModel().tileOverlays.observe(this) { it.forEach(map::addTileOverlay) } - } - /** Opens a dialog for selecting a [MapType] for the basemap layer. */ fun showMapTypeSelectorDialog() { val types = map.supportedMapTypes.toTypedArray() @@ -173,6 +164,6 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { companion object { private val DEFAULT_MAP_CONFIG: MapConfig = - MapConfig(showTileOverlays = true, overrideMapType = null) + MapConfig(showOfflineTileOverlays = true, overrideMapType = null) } } diff --git a/ground/src/main/java/com/google/android/ground/ui/common/BaseMapViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/common/BaseMapViewModel.kt index a426fe5444..1b91c7ba0d 100644 --- a/ground/src/main/java/com/google/android/ground/ui/common/BaseMapViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/common/BaseMapViewModel.kt @@ -55,7 +55,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.withIndex @@ -114,29 +113,22 @@ constructor( } .stateIn(viewModelScope, SharingStarted.Lazily, null) - val tileOverlays: LiveData> - val offlineImageryEnabled: Flow = mapStateRepository.offlineImageryFlow + val offlineTileSources: LiveData> init { mapType = mapStateRepository.mapTypeFlowable.toLiveData() - tileOverlays = - surveyRepository.activeSurveyFlow - .mapNotNull { it?.tileSources?.mapNotNull(this::toLocalTileSource) ?: listOf() } + offlineTileSources = + offlineAreaRepository + .getOfflineTileSourcesFlow() + .combine(mapStateRepository.offlineImageryEnabledFlow) { offlineSources, enabled -> + if (enabled) offlineSources else listOf() + } .asLiveData() viewModelScope.launch(ioDispatcher) { updateCameraPositionOnLocationChange() } viewModelScope.launch(ioDispatcher) { updateCameraPositionOnSurveyChange() } } - // TODO(#1790): Maybe create a new data class object which is not of type TileSource. - private fun toLocalTileSource(tileSource: TileSource): TileSource? { - if (tileSource.type != TileSource.Type.MOG_COLLECTION) return null - return TileSource( - "file://${offlineAreaRepository.getLocalTileSourcePath()}/{z}/{x}/{y}.jpg", - TileSource.Type.TILED_WEB_MAP - ) - } - private suspend fun toggleLocationLock() { if (locationLock.value.getOrDefault(false)) { disableLocationLock() diff --git a/ground/src/main/java/com/google/android/ground/ui/common/MapConfig.kt b/ground/src/main/java/com/google/android/ground/ui/common/MapConfig.kt index 8459e11648..aa652203a0 100644 --- a/ground/src/main/java/com/google/android/ground/ui/common/MapConfig.kt +++ b/ground/src/main/java/com/google/android/ground/ui/common/MapConfig.kt @@ -18,4 +18,4 @@ package com.google.android.ground.ui.common import com.google.android.ground.ui.map.MapType /** Configuration to apply on the rendered base map. */ -data class MapConfig(val showTileOverlays: Boolean, val overrideMapType: MapType?) +data class MapConfig(val showOfflineTileOverlays: Boolean, val overrideMapType: MapType?) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/ClippingTileProvider.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/ClippingTileProvider.kt new file mode 100644 index 0000000000..ad23a9ef58 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/ClippingTileProvider.kt @@ -0,0 +1,54 @@ +/* + * 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 com.google.android.ground.ui.map.gms + +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 com.google.android.ground.ui.map.gms.mog.ImageEditor +import com.google.android.ground.ui.map.gms.mog.TileCoordinates +import com.google.android.ground.ui.map.gms.mog.toPixelBounds +import com.google.android.ground.ui.map.gms.mog.toPixelCoordinate + +private const val MAX_ZOOM = 19 + +class ClippingTileProvider( + private val sourceTileProvider: TileProvider, + clipBounds: List +) : TileProvider { + + private val pixelBounds = clipBounds.map { it.toPixelBounds(MAX_ZOOM) } + + override fun getTile(x: Int, y: Int, zoom: Int): Tile { + val sourceTile = sourceTileProvider.getTile(x, y, zoom) ?: NO_TILE + if (sourceTile == NO_TILE) return sourceTile + // We assume if a tile is returned by the source provider that at least some pixels are within + // the clip bounds, so there's no need to optimize by checking before clipping. + return clipToBounds(TileCoordinates(x, y, zoom), sourceTile) + } + + private fun clipToBounds(tileCoords: TileCoordinates, tile: Tile): Tile { + if (tile.data == null) return NO_TILE + val output = + ImageEditor.setTransparentIf(tile.data!!) { _, x, y -> + val pixelCoords = tileCoords.toPixelCoordinate(x, y) + pixelBounds.none { it.contains(pixelCoords) } + } + return Tile(tile.width, tile.height, output) + } +} diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt index 43ce37d75d..1e98f93ad3 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/GoogleMapsFragment.kt @@ -353,14 +353,16 @@ class GoogleMapsFragment : Hilt_GoogleMapsFragment(), Map { } } - private fun addTemplateUrlTileOverlay(url: String) { - addTileOverlay(TemplateUrlTileProvider(url)) + private fun addOfflineTileOverlay(url: String, bounds: List) { + addTileOverlay( + ClippingTileProvider(TemplateUrlTileProvider(url), bounds.map { it.toGoogleMapsObject() }) + ) } override fun addTileOverlay(tileSource: TileSource) = when (tileSource.type) { MOG_COLLECTION -> addMogCollectionTileOverlay(tileSource.url) - TILED_WEB_MAP -> addTemplateUrlTileOverlay(tileSource.url) + TILED_WEB_MAP -> addOfflineTileOverlay(tileSource.url, tileSource.clipBounds) else -> error("Unsupported tile source type ${tileSource.type}") } diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/TemplateUrlTileProvider.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/TemplateUrlTileProvider.kt index 86736e173a..7bc02685d2 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/TemplateUrlTileProvider.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/TemplateUrlTileProvider.kt @@ -23,12 +23,13 @@ import timber.log.Timber /** * Fetches tile imagery from a server according to a formatted URL. * - * Tile URLs should have the format: host/z/x/y + * `[urlTemplate]` may contain `{z}`, `{x}`, and `{y}`, which is replaced with the coordinates of + * the tile being rendered. */ -class TemplateUrlTileProvider(private val template: String) : UrlTileProvider(256, 256) { +class TemplateUrlTileProvider(private val urlTemplate: String) : UrlTileProvider(256, 256) { override fun getTileUrl(x: Int, y: Int, z: Int): URL? { val url = - template + urlTemplate .replace("{z}", z.toString()) .replace("{x}", x.toString()) .replace("{y}", y.toString()) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/ImageEditor.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/ImageEditor.kt new file mode 100644 index 0000000000..40c75a1903 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/ImageEditor.kt @@ -0,0 +1,53 @@ +/* + * 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 com.google.android.ground.ui.map.gms.mog + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import java.io.ByteArrayOutputStream + +/** Utility for modifying images. */ +object ImageEditor { + /** + * 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. + */ + fun setTransparentIf( + sourceImage: ByteArray, + isTransparent: (bitmap: Bitmap, x: Int, y: Int) -> Boolean + ): ByteArray { + val opts = BitmapFactory.Options() + opts.inMutable = true + val bitmap = BitmapFactory.decodeByteArray(sourceImage, 0, sourceImage.size, opts) + bitmap.setHasAlpha(true) + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (isTransparent(bitmap, x, y)) { + bitmap.setPixel(x, y, Color.TRANSPARENT) + } + } + } + // Android doesn't implement encoders for uncompressed format, so we must compress the returned + // tile so that it can be later encoded by Maps SDK. Experimentally, decompressing JPG and + // compressing WEBP each tile adds on the order of 1ms to each tile which we can consider + // negligible. + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.WEBP, 100, stream) + return stream.toByteArray() + } +} diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MathExt.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MathExt.kt new file mode 100644 index 0000000000..b3938074d0 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MathExt.kt @@ -0,0 +1,30 @@ +/* + * 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 com.google.android.ground.ui.map.gms.mog + +import kotlin.math.abs +import kotlin.math.cos + +/** + * Returns the value shifted left [n] bits when [n] is positive, or right [-n] bits when negative. + */ +fun Int.shiftLeft(n: Int) = if (n >= 0) this shl n else this shr abs(n) + +/** Returns the secant of angle `x` given in radians. */ +fun sec(x: Double) = 1 / cos(x) + +/** Converts degrees into radians. */ +fun Double.toRadians() = this * (Math.PI / 180) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTile.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTile.kt index e394b0a9db..2ac96ba882 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTile.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTile.kt @@ -16,11 +16,8 @@ package com.google.android.ground.ui.map.gms.mog -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Color import com.google.android.gms.maps.model.Tile -import java.io.ByteArrayOutputStream /** Metadata and image data for a single tile. */ class MogTile(val metadata: MogTileMetadata, val data: ByteArray) { @@ -31,32 +28,15 @@ class MogTile(val metadata: MogTileMetadata, val data: ByteArray) { .toByteArray() fun toGmsTile(): Tile { + val noData = metadata.noDataValue val imageData = - if (metadata.noDataValue == null) buildJfifFile() - else maskNoDataValues(buildJfifFile(), metadata.noDataValue) + if (noData == null) buildJfifFile() + else maskNoDataValues(buildJfifFile(), Color.rgb(noData, noData, noData)) return Tile(metadata.width, metadata.height, imageData) } - private fun maskNoDataValues(jfifData: ByteArray, noDataValue: Int): ByteArray { - val opts = BitmapFactory.Options() - opts.inMutable = true - val bitmap = BitmapFactory.decodeByteArray(jfifData, 0, jfifData.size, opts) - bitmap.setHasAlpha(true) - for (y in 0 until bitmap.height) { - for (x in 0 until bitmap.width) { - if (bitmap.getPixel(x, y) == Color.rgb(noDataValue, noDataValue, noDataValue)) { - bitmap.setPixel(x, y, Color.TRANSPARENT) - } - } - } - // Android doesn't implement encoders for uncompressed format, so we must compress the returned - // tile so that it can be later encoded by Maps SDK. Experimentally, decompressing JPG and - // compressing WEBP each tile adds on the order of 1ms to each tile which we can consider - // negligible. - val stream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.WEBP, 100, stream) - return stream.toByteArray() - } + 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 + diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/PixelBounds.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/PixelBounds.kt new file mode 100644 index 0000000000..eea8120584 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/PixelBounds.kt @@ -0,0 +1,32 @@ +/* + * 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 com.google.android.ground.ui.map.gms.mog + +import com.google.android.gms.maps.model.LatLngBounds + +data class PixelBounds(val min: PixelCoordinates, val max: PixelCoordinates) { + fun contains(pixelCoordinates: PixelCoordinates): Boolean { + // Do pixel math at precision of point in highest resolution plane. + val (minX, minY, _) = min.atZoom(pixelCoordinates.zoom) + val (maxX, maxY, _) = max.atZoom(pixelCoordinates.zoom) + val (x, y, _) = pixelCoordinates + return x in minX..maxX && y in minY..maxY + } +} + +fun LatLngBounds.toPixelBounds(zoom: Int) = + PixelBounds(northwest().toPixelCoordinates(zoom), southeast().toPixelCoordinates(zoom)) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/PixelCoordinates.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/PixelCoordinates.kt new file mode 100644 index 0000000000..77fb7bcc08 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/PixelCoordinates.kt @@ -0,0 +1,38 @@ +/* + * 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 com.google.android.ground.ui.map.gms.mog + +import com.google.android.gms.maps.model.LatLng +import kotlin.math.ln +import kotlin.math.tan + +data class PixelCoordinates(val x: Int, val y: Int, val zoom: Int) { + fun atZoom(targetZoom: Int): PixelCoordinates { + val delta = targetZoom - zoom + return PixelCoordinates(x.shiftLeft(delta), y.shiftLeft(delta), targetZoom) + } +} + +fun LatLng.toPixelCoordinates(zoom: Int): PixelCoordinates { + val zoomFactor = 1 shl zoom + val latRad = this.latitude.toRadians() + val x = zoomFactor * (this.longitude + 180) / 360 + val y = zoomFactor * (1 - (ln(tan(latRad) + sec(latRad)) / Math.PI)) / 2 + return PixelCoordinates((x * 256.0).toInt(), (y * 256.0).toInt(), zoom) +} + +fun TileCoordinates.toPixelCoordinate(xOffset: Int, yOffset: Int) = + PixelCoordinates(x * 256 + xOffset, y * 256 + yOffset, zoom) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/TileCoordinates.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/TileCoordinates.kt index a16837094f..f9bab0cb37 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/TileCoordinates.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/TileCoordinates.kt @@ -18,9 +18,6 @@ package com.google.android.ground.ui.map.gms.mog import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds -import java.lang.Math.PI -import kotlin.math.abs -import kotlin.math.cos import kotlin.math.ln import kotlin.math.tan @@ -32,8 +29,7 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) { /** Returns the coordinates of the tile at [targetZoom] with the same northwest corner. */ fun originAtZoom(targetZoom: Int): TileCoordinates { val zoomDelta = targetZoom - zoom - val scale = { x: Int -> if (zoomDelta > 0) x shl zoomDelta else x shr abs(zoomDelta) } - return TileCoordinates(scale(x), scale(y), targetZoom) + return TileCoordinates(x.shiftLeft(zoomDelta), y.shiftLeft(zoomDelta), targetZoom) } override fun toString(): String = "($x, $y) at zoom $zoom" @@ -46,9 +42,10 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) { fun fromLatLng(coordinates: LatLng, zoom: Int): TileCoordinates { val zoomFactor = 1 shl zoom val latRad = coordinates.latitude.toRadians() - val x = zoomFactor * (coordinates.longitude + 180) / 360 - val y = zoomFactor * (1 - (ln(tan(latRad) + sec(latRad)) / PI)) / 2 - return TileCoordinates(x.toInt(), y.toInt(), zoom) + val x1 = zoomFactor * (coordinates.longitude + 180) / 360 + val y1 = zoomFactor * (1 - (ln(tan(latRad) + sec(latRad)) / Math.PI)) / 2 + val (x, y) = PixelCoordinates((x1 * 256.0).toInt(), (y1 * 256.0).toInt(), zoom) + return TileCoordinates(x / 256, y / 256, zoom) } /** @@ -68,9 +65,3 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) { } } } - -/** Returns the secant of angle `x` given in radians. */ -private fun sec(x: Double) = 1 / cos(x) - -/** Converts degrees into radians. */ -private fun Double.toRadians() = this * (PI / 180) diff --git a/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorFragment.kt b/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorFragment.kt index d0915aa7cd..538585d2ec 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorFragment.kt @@ -65,5 +65,5 @@ class OfflineAreaSelectorFragment : Hilt_OfflineAreaSelectorFragment() { override fun getMapViewModel(): BaseMapViewModel = viewModel override fun getMapConfig(): MapConfig = - super.getMapConfig().copy(showTileOverlays = false, overrideMapType = MapType.ROAD) + super.getMapConfig().copy(showOfflineTileOverlays = false, overrideMapType = MapType.ROAD) } diff --git a/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorViewModel.kt index 2786ddde75..b64e9c3928 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/selector/OfflineAreaSelectorViewModel.kt @@ -69,7 +69,7 @@ internal constructor( ioDispatcher ) { - val tileSources: List + val remoteTileSources: List private var viewport: Bounds? = null val isDownloadProgressVisible = MutableLiveData(false) val downloadProgressMax = MutableLiveData(0) @@ -79,7 +79,7 @@ internal constructor( val downloadButtonEnabled = MutableLiveData(false) init { - tileSources = surveyRepository.activeSurvey!!.tileSources + remoteTileSources = surveyRepository.activeSurvey!!.tileSources } fun onDownloadClick() { @@ -107,7 +107,7 @@ internal constructor( } fun onMapReady(map: Map) { - tileSources.forEach { map.addTileOverlay(it) } + remoteTileSources.forEach { map.addTileOverlay(it) } disposeOnClear(cameraBoundUpdates.subscribe { viewport = it }) } diff --git a/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/viewer/OfflineAreaViewerFragment.kt b/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/viewer/OfflineAreaViewerFragment.kt index e90fb9ea5c..7833dc16ea 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/viewer/OfflineAreaViewerFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlinebasemap/viewer/OfflineAreaViewerFragment.kt @@ -64,7 +64,7 @@ class OfflineAreaViewerFragment @Inject constructor() : Hilt_OfflineAreaViewerFr override fun getMapViewModel(): BaseMapViewModel = viewModel override fun getMapConfig(): MapConfig = - super.getMapConfig().copy(showTileOverlays = false, overrideMapType = MapType.ROAD) + super.getMapConfig().copy(showOfflineTileOverlays = false, overrideMapType = MapType.ROAD) private fun panMap(offlineArea: OfflineArea) { map.viewport = offlineArea.bounds diff --git a/ground/src/test/java/com/google/android/ground/repository/MapStateRepositoryTest.kt b/ground/src/test/java/com/google/android/ground/repository/MapStateRepositoryTest.kt index 634dec6739..cee6d8f83d 100644 --- a/ground/src/test/java/com/google/android/ground/repository/MapStateRepositoryTest.kt +++ b/ground/src/test/java/com/google/android/ground/repository/MapStateRepositoryTest.kt @@ -73,6 +73,8 @@ class MapStateRepositoryTest : BaseHiltTest() { fun isOfflineImageryEnabled_whenEnabled_returnsTrue() = runWithTestDispatcher { mapStateRepository.isOfflineImageryEnabled = true - mapStateRepository.offlineImageryFlow.test { assertThat(expectMostRecentItem()).isTrue() } + mapStateRepository.offlineImageryEnabledFlow.test { + assertThat(expectMostRecentItem()).isTrue() + } } } diff --git a/ground/src/test/java/com/google/android/ground/ui/map/gms/mog/PixelCoordinatesTest.kt b/ground/src/test/java/com/google/android/ground/ui/map/gms/mog/PixelCoordinatesTest.kt new file mode 100644 index 0000000000..aea9f09f64 --- /dev/null +++ b/ground/src/test/java/com/google/android/ground/ui/map/gms/mog/PixelCoordinatesTest.kt @@ -0,0 +1,76 @@ +/* + * 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 com.google.android.ground.ui.map.gms.mog + +import com.google.android.gms.maps.model.LatLng +import kotlin.test.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PixelCoordinatesTest { + @Test + fun `atZoom() at lower zoom reduces coords`() { + val actual = PixelCoordinates(128, 256, 6).atZoom(4) + val expected = PixelCoordinates(32, 64, 4) + assertEquals(expected, actual) + } + + @Test + fun `atZoom() at higher zoom increases coords`() { + val actual = PixelCoordinates(256, 512, 0).atZoom(3) + val expected = PixelCoordinates(2048, 4096, 3) + assertEquals(expected, actual) + } + + @Test + fun `atZoom() at same zoom no-op`() { + val actual = PixelCoordinates(128, 32, 5).atZoom(5) + val expected = PixelCoordinates(128, 32, 5) + assertEquals(expected, actual) + } + + @Test + fun `LatLng#toPixelCoordinates() at zoom 0 returns correct values`() { + // jsfiddle used to find examples: https://jsfiddle.net/gmiceli/pjhy4Lfm/16/ + val actual = LatLng(40.6874, -73.9306).toPixelCoordinates(0) + val expected = PixelCoordinates(75, 96, 0) + assertEquals(expected, actual) + } + + @Test + fun `LatLng#toPixelCoordinates() at intermediate zoom returns correct values`() { + // jsfiddle used to find examples: https://jsfiddle.net/gmiceli/pjhy4Lfm/16/ + val actual = LatLng(41.876, 12.4757).toPixelCoordinates(3) + val expected = PixelCoordinates(1094, 761, 3) + assertEquals(expected, actual) + } + + @Test + fun `TileCoordinates#toPixelCoordinate() at zoom 0 returns valid result`() { + val expected = PixelCoordinates(128, 128, 0) + val actual = TileCoordinates(0, 0, 0).toPixelCoordinate(128, 128) + assertEquals(expected, actual) + } + + @Test + fun `TileCoordinates#toPixelCoordinate() at intermediate zoom returns valid result`() { + val expected = PixelCoordinates(527, 537, 2) + val actual = TileCoordinates(2, 2, 2).toPixelCoordinate(15, 25) + assertEquals(expected, actual) + } +}