Skip to content

Commit

Permalink
Clip offline tiles to downloaded bounds (#1852)
Browse files Browse the repository at this point in the history
* Remove unused enum

* WIP

* Update string resources

* Fix detekt error

* Fix typo

* Rename

* Refactor

* Refactor

* Offline area map selector mask and outline

* Update Cancel and Download layout

* Remove unused resource

* Merge offline area estimator with latest UI

* Fix force quit

* Remove Apache Commons dep

* Impl local offline area flow

* Apply constructor method pattern to TileSource creation

* Docs tweaks

* Add tile + offset -> latlng

* Add clipping tile provider

* Minor refactor

* Update table name missed on rename

* Upgrade Room

* WIP

* Clip offline imagery to selected areas

* checkCode

* Add tests

* Clean up Flow

* Clean up comment

* Update comment

* Refactor transparency log

* Remove redundant setting of map type

* Make clip bounds non-nullable

* ktfmt
  • Loading branch information
gino-m authored Sep 11, 2023
1 parent cae33b4 commit 59e4c9f
Show file tree
Hide file tree
Showing 25 changed files with 366 additions and 108 deletions.
5 changes: 1 addition & 4 deletions ground/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion ground/src/main/java/com/google/android/ground/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bounds> = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ import io.reactivex.Maybe
/** Provides read/write operations for writing [OfflineAreaEntity] to the local db. */
@Dao
interface OfflineAreaDao : BaseDao<OfflineAreaEntity> {
@Query("SELECT * FROM offline_base_map")
fun findAllOnceAndStream(): Flowable<List<OfflineAreaEntity>>
@Query("SELECT * FROM offline_area") fun findAllOnceAndStream(): Flowable<List<OfflineAreaEntity>>

@Query("SELECT * FROM offline_base_map WHERE id = :id")
@Query("SELECT * FROM offline_area WHERE id = :id")
fun findById(id: String): Maybe<OfflineAreaEntity>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ internal object SurveyConverter {
private fun convertTileSources(pd: SurveyDocument, builder: MutableList<TileSource>) {
pd.tileSources
?.mapNotNull { it.url }
?.forEach { url -> builder.add(TileSource(url, TileSource.fromFileExtension(url))) }
?.forEach { url -> builder.add(TileSource(url, TileSource.Type.MOG_COLLECTION)) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class MapStateRepository @Inject constructor(private val localValueStore: LocalV
val mapTypeFlowable: Flowable<MapType> by localValueStore::mapTypeFlowable
var mapType: MapType by localValueStore::mapType
var isLocationLockEnabled: Boolean by localValueStore::isLocationLockEnabled
val offlineImageryFlow: Flow<Boolean> by localValueStore::offlineImageryEnabledFlow
val offlineImageryEnabledFlow: Flow<Boolean> by localValueStore::offlineImageryEnabledFlow
var isOfflineImageryEnabled: Boolean by localValueStore::isOfflineImageryEnabled

fun setCameraPosition(cameraPosition: CameraPosition) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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<List<TileSource>> =
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<TileSource>?, bounds: List<Bounds>): List<TileSource> =
tileSources?.mapNotNull { tileSource -> toOfflineTileSource(tileSource, bounds) } ?: listOf()

private fun toOfflineTileSource(tileSource: TileSource, clipBounds: List<Bounds>): 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<List<Bounds>> =
localOfflineAreaStore.offlineAreasOnceAndStream().map { list -> list.map { it.bounds } }

/**
* Uses the first tile source URL of the currently active survey and returns a [MogClient], or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,29 +113,22 @@ constructor(
}
.stateIn(viewModelScope, SharingStarted.Lazily, null)

val tileOverlays: LiveData<List<TileSource>>
val offlineImageryEnabled: Flow<Boolean> = mapStateRepository.offlineImageryFlow
val offlineTileSources: LiveData<List<TileSource>>

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Original file line number Diff line number Diff line change
@@ -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<LatLngBounds>
) : 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -353,14 +353,16 @@ class GoogleMapsFragment : Hilt_GoogleMapsFragment(), Map {
}
}

private fun addTemplateUrlTileOverlay(url: String) {
addTileOverlay(TemplateUrlTileProvider(url))
private fun addOfflineTileOverlay(url: String, bounds: List<Bounds>) {
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}")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading

0 comments on commit 59e4c9f

Please sign in to comment.