Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clip offline tiles to downloaded bounds #1852

Merged
merged 41 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
b6f72cd
Remove unused enum
gino-m Sep 7, 2023
ec7e4e8
WIP
gino-m Sep 7, 2023
af92c01
Update string resources
gino-m Sep 8, 2023
56e2142
Fix detekt error
gino-m Sep 8, 2023
46c86e7
Fix typo
gino-m Sep 8, 2023
e157b29
Rename
gino-m Sep 8, 2023
a70528e
Refactor
gino-m Sep 8, 2023
76b1c0c
Refactor
gino-m Sep 8, 2023
b1a3d25
Offline area map selector mask and outline
gino-m Sep 8, 2023
8d92280
Merge branch 'master' of https://github.com/google/ground-android int…
gino-m Sep 8, 2023
234cb85
Update Cancel and Download layout
gino-m Sep 8, 2023
d031b27
Merge branch 'gino-m/1822/3' into gino-m/1822/1
gino-m Sep 8, 2023
1fd58f9
Remove unused resource
gino-m Sep 8, 2023
2fdce91
Merge branch 'gino-m/1822/3' into gino-m/1822/1
gino-m Sep 8, 2023
9c85437
Merge offline area estimator with latest UI
gino-m Sep 8, 2023
6f7a998
Merge branch 'master' of https://github.com/google/ground-android int…
gino-m Sep 8, 2023
71ed1a7
Fix force quit
gino-m Sep 8, 2023
30ef18e
Remove Apache Commons dep
gino-m Sep 9, 2023
f10dc51
Impl local offline area flow
gino-m Sep 9, 2023
9e82aad
Apply constructor method pattern to TileSource creation
gino-m Sep 9, 2023
d1f2463
Docs tweaks
gino-m Sep 9, 2023
6ab7e45
Merge branch 'gino-m/1822/1' of https://github.com/google/ground-andr…
gino-m Sep 9, 2023
720a676
Add tile + offset -> latlng
gino-m Sep 9, 2023
fefeaf8
Add clipping tile provider
gino-m Sep 9, 2023
0dfce04
Minor refactor
gino-m Sep 9, 2023
14b4f2a
Update table name missed on rename
gino-m Sep 9, 2023
948a402
Upgrade Room
gino-m Sep 9, 2023
88f44bc
WIP
gino-m Sep 9, 2023
7db3ec3
Merge branch 'master' of https://github.com/google/ground-android int…
gino-m Sep 9, 2023
bba7ed9
Clip offline imagery to selected areas
gino-m Sep 10, 2023
b974973
checkCode
gino-m Sep 10, 2023
fc90e51
Merge branch 'master' of https://github.com/google/ground-android int…
gino-m Sep 10, 2023
5e171d2
Add tests
gino-m Sep 10, 2023
eff3fce
Clean up Flow
gino-m Sep 10, 2023
3b15304
Clean up comment
gino-m Sep 10, 2023
21fa63b
Update comment
gino-m Sep 10, 2023
2b68694
Refactor transparency log
gino-m Sep 10, 2023
bffcbd3
Merge branch 'master' into gino-m/1850/1
gino-m Sep 11, 2023
8b264d3
Remove redundant setting of map type
gino-m Sep 11, 2023
ecd9342
Make clip bounds non-nullable
gino-m Sep 11, 2023
53c3826
ktfmt
gino-m Sep 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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