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 9a07978f93..bc36fa6f01 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 = 107 + const val DB_VERSION = 108 const val DB_NAME = "ground.db" // Firebase Cloud Firestore settings. diff --git a/ground/src/main/java/com/google/android/ground/model/imagery/OfflineArea.kt b/ground/src/main/java/com/google/android/ground/model/imagery/OfflineArea.kt index e45a6f5a1e..e93f642608 100644 --- a/ground/src/main/java/com/google/android/ground/model/imagery/OfflineArea.kt +++ b/ground/src/main/java/com/google/android/ground/model/imagery/OfflineArea.kt @@ -16,9 +16,20 @@ package com.google.android.ground.model.imagery import com.google.android.ground.ui.map.Bounds +import com.google.android.ground.ui.map.gms.mog.TileCoordinates /** An area is a contiguous set of tiles that task a geodesic rectangle. */ -data class OfflineArea(val id: String, val state: State, val bounds: Bounds, val name: String) { +data class OfflineArea( + val id: String, + val state: State, + val bounds: Bounds, + val name: String, + /** The range of zoom levels downloaded. */ + val zoomRange: IntRange +) { + val tiles + get() = zoomRange.flatMap { TileCoordinates.withinBounds(bounds, it) } + enum class State { PENDING, IN_PROGRESS, diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt index da67330127..75b0866014 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/converter/ConverterExt.kt @@ -249,14 +249,22 @@ fun OfflineArea.toOfflineAreaEntity() = north = this.bounds.north, east = this.bounds.east, south = this.bounds.south, - west = this.bounds.west + west = this.bounds.west, + minZoom = this.zoomRange.first, + maxZoom = this.zoomRange.last ) fun OfflineAreaEntity.toModelObject(): OfflineArea { val northEast = Coordinates(this.north, this.east) val southWest = Coordinates(this.south, this.west) val bounds = Bounds(southWest, northEast) - return OfflineArea(this.id, this.state.toModelObject(), bounds, this.name) + return OfflineArea( + this.id, + this.state.toModelObject(), + bounds, + this.name, + IntRange(minZoom, maxZoom) + ) } fun Option.toLocalDataStoreObject(taskId: String) = 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 0c6866ff81..2a573996b8 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 @@ -28,4 +28,6 @@ interface OfflineAreaDao : BaseDao { @Query("SELECT * FROM offline_area WHERE id = :id") fun findById(id: String): Maybe + + @Query("DELETE FROM offline_area WHERE id = :id") suspend fun deleteById(id: String) } 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 2dcd23199b..3912abd021 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 @@ -30,5 +30,7 @@ data class OfflineAreaEntity( @ColumnInfo(name = "north") val north: Double, @ColumnInfo(name = "south") val south: Double, @ColumnInfo(name = "east") val east: Double, - @ColumnInfo(name = "west") val west: Double + @ColumnInfo(name = "west") val west: Double, + @ColumnInfo(name = "min_zoom") val minZoom: Int, + @ColumnInfo(name = "max_zoom") val maxZoom: Int ) diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomOfflineAreaStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomOfflineAreaStore.kt index 5c4d7fa7a7..0e6b003d35 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomOfflineAreaStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/room/stores/RoomOfflineAreaStore.kt @@ -23,12 +23,10 @@ import com.google.android.ground.persistence.local.room.dao.insertOrUpdate import com.google.android.ground.persistence.local.room.entity.OfflineAreaEntity import com.google.android.ground.persistence.local.stores.LocalOfflineAreaStore import com.google.android.ground.rx.Schedulers -import io.reactivex.Completable import io.reactivex.Flowable import io.reactivex.Single import javax.inject.Inject import javax.inject.Singleton -import timber.log.Timber @Singleton class RoomOfflineAreaStore @Inject internal constructor() : LocalOfflineAreaStore { @@ -47,11 +45,6 @@ class RoomOfflineAreaStore @Inject internal constructor() : LocalOfflineAreaStor override fun getOfflineAreaById(id: String): Single = offlineAreaDao.findById(id).map { it.toModelObject() }.toSingle().subscribeOn(schedulers.io()) - override fun deleteOfflineArea(offlineAreaId: String): Completable = - offlineAreaDao - .findById(offlineAreaId) - .toSingle() - .doOnSubscribe { Timber.d("Deleting offline area: $offlineAreaId") } - .flatMapCompletable { offlineAreaDao.delete(it) } - .subscribeOn(schedulers.io()) + override suspend fun deleteOfflineArea(offlineAreaId: String) = + offlineAreaDao.deleteById(offlineAreaId) } diff --git a/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalOfflineAreaStore.kt b/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalOfflineAreaStore.kt index 882594a623..52aed0ffcc 100644 --- a/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalOfflineAreaStore.kt +++ b/ground/src/main/java/com/google/android/ground/persistence/local/stores/LocalOfflineAreaStore.kt @@ -17,7 +17,6 @@ package com.google.android.ground.persistence.local.stores import com.google.android.ground.model.imagery.OfflineArea import com.google.android.ground.rx.annotations.Cold -import io.reactivex.Completable import io.reactivex.Flowable import io.reactivex.Single @@ -32,7 +31,7 @@ interface LocalOfflineAreaStore { fun offlineAreasOnceAndStream(): @Cold(terminates = false) Flowable> /** Delete an offline area and any associated tiles that are no longer needed. */ - fun deleteOfflineArea(offlineAreaId: String): @Cold Completable + suspend fun deleteOfflineArea(offlineAreaId: String) /** Returns the offline area with the specified id. */ fun getOfflineAreaById(id: String): Single 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 0c403ddf02..0b35319b35 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 @@ -24,14 +24,18 @@ import com.google.android.ground.rx.annotations.Cold import com.google.android.ground.system.GeocodingManager import com.google.android.ground.ui.map.Bounds import com.google.android.ground.ui.map.gms.mog.* -import com.google.android.ground.ui.map.gms.toGoogleMapsObject import com.google.android.ground.ui.util.FileUtil +import com.google.android.ground.util.ByteCount +import com.google.android.ground.util.deleteIfEmpty +import com.google.android.ground.util.rangeOf import io.reactivex.* import java.io.File import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.* import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirst +import timber.log.Timber /** * Corners of the viewport are scaled by this value when determining the name of downloaded areas. @@ -50,14 +54,15 @@ constructor( private val offlineUuidGenerator: OfflineUuidGenerator ) { - private suspend fun addOfflineArea(bounds: Bounds) { + private suspend fun addOfflineArea(bounds: Bounds, zoomRange: IntRange) { val areaName = geocodingManager.getAreaName(bounds.shrink(AREA_NAME_SENSITIVITY)) localOfflineAreaStore.insertOrUpdate( OfflineArea( offlineUuidGenerator.generateUuid(), OfflineArea.State.DOWNLOADED, bounds, - areaName + areaName, + zoomRange ) ) } @@ -82,7 +87,7 @@ constructor( */ suspend fun downloadTiles(bounds: Bounds): Flow> = flow { val client = getMogClient() - val requests = client.buildTilesRequests(bounds.toGoogleMapsObject()) + val requests = client.buildTilesRequests(bounds) val totalBytes = requests.sumOf { it.totalBytes } var bytesDownloaded = 0 val tilePath = getLocalTileSourcePath() @@ -91,7 +96,8 @@ constructor( emit(Pair(bytesDownloaded, totalBytes)) } if (bytesDownloaded > 0) { - addOfflineArea(bounds) + val zoomRange = requests.flatMap { it.tiles }.rangeOf { it.tileCoordinates.zoom } + addOfflineArea(bounds, zoomRange) } } @@ -131,12 +137,13 @@ constructor( * throws an error if no survey is active or if no tile sources are defined. */ private fun getMogClient(): MogClient { - val baseUrl = getFirstTileSourceUrl() - val mogCollection = MogCollection(Config.getMogSources(baseUrl)) + val mogCollection = MogCollection(getMogSources()) // TODO(#1754): Create a factory and inject rather than instantiating here. Add tests. return MogClient(mogCollection) } + private fun getMogSources(): List = Config.getMogSources(getFirstTileSourceUrl()) + /** * Returns the URL of the first tile source in the current survey, or throws an error if no survey * is active or if no tile sources are defined. @@ -147,13 +154,38 @@ constructor( suspend fun hasHiResImagery(bounds: Bounds): Boolean { val client = getMogClient() - val maxZoom = client.collection.maxZoom - return client.buildTilesRequests(bounds.toGoogleMapsObject(), maxZoom..maxZoom).isNotEmpty() + val maxZoom = client.collection.sources.maxZoom() + return client.buildTilesRequests(bounds, maxZoom..maxZoom).isNotEmpty() } suspend fun estimateSizeOnDisk(bounds: Bounds): Int { val client = getMogClient() - val requests = client.buildTilesRequests(bounds.toGoogleMapsObject()) + val requests = client.buildTilesRequests(bounds) return requests.sumOf { it.totalBytes } } + + /** Returns the number of bytes occupied by tiles on the local device. */ + suspend fun sizeOnDevice(offlineArea: OfflineArea): ByteCount = + offlineArea.tiles.sumOf { File(getLocalTileSourcePath(), it.getTilePath()).length().toInt() } + + /** + * Deletes the provided offline area from the device, including all associated unused tiles on the + * local filesystem. Folders containing the deleted tiles are also removed if empty. + */ + suspend fun removeFromDevice(offlineArea: OfflineArea) { + val tilesInSelectedArea = offlineArea.tiles + if (tilesInSelectedArea.isEmpty()) Timber.w("No tiles associate with offline area $offlineArea") + localOfflineAreaStore.deleteOfflineArea(offlineArea.id) + val remainingAreas = localOfflineAreaStore.offlineAreasOnceAndStream().awaitFirst() + val remainingTiles = remainingAreas.flatMap { it.tiles }.toSet() + val tilesToRemove = tilesInSelectedArea - remainingTiles + val tileSourcePath = getLocalTileSourcePath() + tilesToRemove.forEach { + with(File(tileSourcePath, it.getTilePath())) { + delete() + parentFile?.deleteIfEmpty() + parentFile?.parentFile?.deleteIfEmpty() + } + } + } } diff --git a/ground/src/main/java/com/google/android/ground/ui/map/Bounds.kt b/ground/src/main/java/com/google/android/ground/ui/map/Bounds.kt index a7a8b55945..93a4ca8c48 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/Bounds.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/Bounds.kt @@ -22,16 +22,30 @@ import com.google.android.ground.model.geometry.Coordinates * northeast coordinates. */ data class Bounds(val southwest: Coordinates, val northeast: Coordinates) { + // Suppress false-positive on constructor order. + @Suppress("detekt:ClassOrdering") + constructor( + south: Double, + west: Double, + north: Double, + east: Double + ) : this(Coordinates(south, west), Coordinates(north, east)) + val north get() = northeast.lat + val east get() = northeast.lng + val south get() = southwest.lat + val west get() = southwest.lng + val northwest get() = Coordinates(north, west) + val southeast get() = Coordinates(south, east) /** diff --git a/ground/src/main/java/com/google/android/ground/ui/map/CameraPosition.kt b/ground/src/main/java/com/google/android/ground/ui/map/CameraPosition.kt index 1469b11317..6aa476e9f2 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/CameraPosition.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/CameraPosition.kt @@ -48,14 +48,14 @@ data class CameraPosition( val long = parts[1].trim().toDouble() val zoomLevel = parts[2].trim().toFloatOrNull() val isAllowZoomOut = parts[3].trim().toBoolean() - val swLat = parts[4].trim().toDoubleOrNull() - val swLong = parts[5].trim().toDoubleOrNull() - val neLat = parts[6].trim().toDoubleOrNull() - val neLong = parts[7].trim().toDoubleOrNull() + val south = parts[4].trim().toDoubleOrNull() + val west = parts[5].trim().toDoubleOrNull() + val north = parts[6].trim().toDoubleOrNull() + val east = parts[7].trim().toDoubleOrNull() var bounds: Bounds? = null - if (swLat != null && swLong != null && neLat != null && neLong != null) { - bounds = Bounds(Coordinates(swLat, swLong), Coordinates(neLat, neLong)) + if (south != null && west != null && north != null && east != null) { + bounds = Bounds(south, west, north, east) } return CameraPosition(Coordinates(lat, long), zoomLevel, isAllowZoomOut, bounds) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogClient.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogClient.kt index 51f15f4e29..d7dedfa7fe 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogClient.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogClient.kt @@ -17,7 +17,7 @@ package com.google.android.ground.ui.map.gms.mog import android.util.LruCache -import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.ground.ui.map.Bounds import java.io.FileNotFoundException import java.io.InputStream import kotlinx.coroutines.Deferred @@ -50,18 +50,15 @@ class MogClient(val collection: MogCollection) { * [MogCollection.maxZoom]). */ suspend fun buildTilesRequests( - tileBounds: LatLngBounds, - zoomRange: IntRange = IntRange(collection.minZoom, collection.maxZoom) + tileBounds: Bounds, + zoomRange: IntRange = collection.sources.zoomRange() ) = zoomRange .flatMap { zoom -> buildTileRequests(tileBounds, zoom) } .consolidate(MAX_OVER_FETCH_PER_TILE) /** Returns requests for tiles in the specified bounds and zoom, one request per tile. */ - private suspend fun buildTileRequests( - tileBounds: LatLngBounds, - zoom: Int - ): List { + private suspend fun buildTileRequests(tileBounds: Bounds, zoom: Int): List { val mogSource = collection.getMogSource(zoom) ?: return listOf() return TileCoordinates.withinBounds(tileBounds, zoom).mapNotNull { buildTileRequest(mogSource, it) @@ -117,7 +114,7 @@ class MogClient(val collection: MogCollection) { */ private fun getTileMetadata( mogMetadata: MogMetadata, - tileBounds: LatLngBounds, + tileBounds: Bounds, zoom: Int ): List = TileCoordinates.withinBounds(tileBounds, zoom).mapNotNull { tileCoordinates -> diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogCollection.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogCollection.kt index f171d054a9..717552631e 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogCollection.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogCollection.kt @@ -17,9 +17,6 @@ package com.google.android.ground.ui.map.gms.mog /** A collection of Maps Optimized GeoTIFFs (MOGs). */ -class MogCollection(private val mogSources: List) { - val minZoom = mogSources.minOf { it.zoomRange.first } - val maxZoom = mogSources.maxOf { it.zoomRange.last } - - fun getMogSource(zoom: Int) = mogSources.firstOrNull { it.zoomRange.contains(zoom) } +class MogCollection(val sources: List) { + fun getMogSource(zoom: Int) = sources.firstOrNull { it.zoomRange.contains(zoom) } } diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogSource.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogSource.kt index c94931555c..e132bb7066 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogSource.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogSource.kt @@ -46,3 +46,9 @@ data class MogSource(val urlTemplate: String, val zoomRange: IntRange) { return urlTemplate.replace("{x}", mogBounds.x.toString()).replace("{y}", mogBounds.y.toString()) } } + +fun List.minZoom() = minOf { it.zoomRange.first } + +fun List.maxZoom() = maxOf { it.zoomRange.last } + +fun List.zoomRange() = IntRange(minZoom(), maxZoom()) diff --git a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTileDownloader.kt b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTileDownloader.kt index 83808d3a5c..a5b66b0eea 100644 --- a/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTileDownloader.kt +++ b/ground/src/main/java/com/google/android/ground/ui/map/gms/mog/MogTileDownloader.kt @@ -34,11 +34,9 @@ class MogTileDownloader(private val client: MogClient, private val outputBasePat */ suspend fun downloadTiles(requests: List) = flow { client.getTiles(requests).collect { tile -> - val (x, y, zoom) = tile.metadata.tileCoordinates - val path = File(outputBasePath, "$zoom/$x") - path.mkdirs() + val outFile = File(outputBasePath, tile.metadata.tileCoordinates.getTilePath()) + outFile.parentFile?.mkdirs() val gmsTile = tile.toGmsTile() - val outFile = File(path, "$y.jpg") val data = gmsTile.data!! outFile.writeBytes(data) Timber.d("Saved ${data.size} bytes to ${outFile.path}") @@ -46,3 +44,5 @@ class MogTileDownloader(private val client: MogClient, private val outputBasePat } } } + +fun TileCoordinates.getTilePath() = "$zoom/$x/$y.jpg" 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 f9bab0cb37..0a9a3952c3 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 @@ -16,8 +16,8 @@ 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 com.google.android.ground.model.geometry.Coordinates +import com.google.android.ground.ui.map.Bounds import kotlin.math.ln import kotlin.math.tan @@ -39,10 +39,10 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) { * Returns the coordinates of the tile at a particular zoom containing the specified latitude * and longitude coordinates. */ - fun fromLatLng(coordinates: LatLng, zoom: Int): TileCoordinates { + fun fromCoordinates(coordinates: Coordinates, zoom: Int): TileCoordinates { val zoomFactor = 1 shl zoom - val latRad = coordinates.latitude.toRadians() - val x1 = zoomFactor * (coordinates.longitude + 180) / 360 + val latRad = coordinates.lat.toRadians() + val x1 = zoomFactor * (coordinates.lng + 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) @@ -52,10 +52,10 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) { * Returns all tiles at a particular zoom contained within the specified latitude and longitude * bounds. */ - fun withinBounds(bounds: LatLngBounds, zoom: Int): List { + fun withinBounds(bounds: Bounds, zoom: Int): List { val results = mutableListOf() - val nwTile = fromLatLng(bounds.northwest(), zoom) - val seTile = fromLatLng(bounds.southeast(), zoom) + val nwTile = fromCoordinates(bounds.northwest, zoom) + val seTile = fromCoordinates(bounds.southeast, zoom) for (y in nwTile.y..seTile.y) { for (x in nwTile.x..seTile.x) { results.add(TileCoordinates(x, y, zoom)) diff --git a/ground/src/main/java/com/google/android/ground/ui/offlineareas/OfflineAreasViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/offlineareas/OfflineAreasViewModel.kt index 2187de1a9c..9ad951ec0f 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlineareas/OfflineAreasViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlineareas/OfflineAreasViewModel.kt @@ -54,6 +54,7 @@ internal constructor( .offlineAreasOnceAndStream() .doOnError { Timber.e(it, "Unexpected error loading offline areas from the local db") } .onErrorReturnItem(listOf()) + this.offlineAreas = offlineAreas.toLiveData() noAreasMessageVisibility = offlineAreas.map { if (it.isEmpty()) View.VISIBLE else View.GONE }.toLiveData() diff --git a/ground/src/main/java/com/google/android/ground/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt index e74d02f9a2..1aeeb61ed4 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt @@ -35,8 +35,9 @@ import com.google.android.ground.ui.common.SharedViewModel import com.google.android.ground.ui.map.Bounds import com.google.android.ground.ui.map.CameraPosition import com.google.android.ground.ui.map.MapType +import com.google.android.ground.util.toMb +import com.google.android.ground.util.toMbString import javax.inject.Inject -import kotlin.math.ceil import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch @@ -51,7 +52,7 @@ internal constructor( private val offlineAreaRepository: OfflineAreaRepository, private val navigator: Navigator, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val resources: Resources, + resources: Resources, locationManager: LocationManager, surveyRepository: SurveyRepository, mapStateRepository: MapStateRepository, @@ -72,16 +73,17 @@ internal constructor( val remoteTileSources: List private var viewport: Bounds? = null + private val offlineAreaSizeLoadingSymbol = + resources.getString(R.string.offline_area_size_loading_symbol) val isDownloadProgressVisible = MutableLiveData(false) val downloadProgressMax = MutableLiveData(0) val downloadProgress = MutableLiveData(0) val sizeOnDisk = MutableLiveData(null) val visibleBottomTextViewId = MutableLiveData(null) val downloadButtonEnabled = MutableLiveData(false) - val offlineAreaSizeLoadingSymbol = resources.getString(R.string.offline_area_size_loading_symbol) override val mapConfig: MapConfig - get() = super.mapConfig.copy(showOfflineTileOverlays = false, overrideMapType = MapType.ROAD) + get() = super.mapConfig.copy(showOfflineTileOverlays = false, overrideMapType = MapType.TERRAIN) init { remoteTileSources = surveyRepository.activeSurvey!!.tileSources @@ -139,7 +141,7 @@ internal constructor( } sizeOnDisk.postValue(offlineAreaSizeLoadingSymbol) visibleBottomTextViewId.postValue(R.id.size_on_disk_text_view) - val sizeInMb = offlineAreaRepository.estimateSizeOnDisk(bounds) / (1024f * 1024f) + val sizeInMb = offlineAreaRepository.estimateSizeOnDisk(bounds).toMb() if (sizeInMb > MAX_AREA_DOWNLOAD_SIZE_MB) { onLargeAreaSelected() } else { @@ -153,8 +155,7 @@ internal constructor( } private fun onDownloadableAreaSelected(sizeInMb: Float) { - val sizeString = if (sizeInMb < 1f) "<1" else ceil(sizeInMb).toInt().toString() - sizeOnDisk.postValue(sizeString) + sizeOnDisk.postValue(sizeInMb.toMbString()) visibleBottomTextViewId.postValue(R.id.size_on_disk_text_view) downloadButtonEnabled.postValue(true) } diff --git a/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt b/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt index 963150d179..326e8a3f93 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerFragment.kt @@ -19,12 +19,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.ground.databinding.OfflineAreaViewerFragBinding -import com.google.android.ground.model.imagery.OfflineArea import com.google.android.ground.ui.common.AbstractMapContainerFragment import com.google.android.ground.ui.common.BaseMapViewModel +import com.google.android.ground.ui.map.MapFragment import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch /** The fragment provides a UI for managing a single offline area on the user's device. */ @AndroidEntryPoint(AbstractMapContainerFragment::class) @@ -36,8 +40,16 @@ class OfflineAreaViewerFragment @Inject constructor() : Hilt_OfflineAreaViewerFr super.onCreate(savedInstanceState) val args = OfflineAreaViewerFragmentArgs.fromBundle(requireNotNull(arguments)) viewModel = getViewModel(OfflineAreaViewerViewModel::class.java) - viewModel.loadOfflineArea(args) - viewModel.offlineArea.observe(this) { offlineArea: OfflineArea -> panMap(offlineArea) } + viewModel.initialize(args) + } + + override fun onMapReady(map: MapFragment) { + super.onMapReady(map) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.area.observe(this@OfflineAreaViewerFragment) { map.viewport = it.bounds } + } + } } override fun onCreateView( @@ -49,19 +61,9 @@ class OfflineAreaViewerFragment @Inject constructor() : Hilt_OfflineAreaViewerFr val binding = OfflineAreaViewerFragBinding.inflate(inflater, container, false) binding.viewModel = viewModel binding.lifecycleOwner = this - binding.removeButton.setOnClickListener { onRemoveClick() } getAbstractActivity().setActionBar(binding.offlineAreaViewerToolbar, true) return binding.root } override fun getMapViewModel(): BaseMapViewModel = viewModel - - private fun panMap(offlineArea: OfflineArea) { - map.viewport = offlineArea.bounds - } - - /** Removes the area associated with this fragment from the user's device. */ - private fun onRemoveClick() { - viewModel.removeArea() - } } diff --git a/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt b/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt index 4c6c4361f3..a9b71c5e4e 100644 --- a/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt +++ b/ground/src/main/java/com/google/android/ground/ui/offlineareas/viewer/OfflineAreaViewerViewModel.kt @@ -15,30 +15,28 @@ */ package com.google.android.ground.ui.offlineareas.viewer -import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.toLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope import com.google.android.ground.coroutines.IoDispatcher import com.google.android.ground.model.imagery.OfflineArea import com.google.android.ground.repository.LocationOfInterestRepository import com.google.android.ground.repository.MapStateRepository import com.google.android.ground.repository.OfflineAreaRepository import com.google.android.ground.repository.SurveyRepository -import com.google.android.ground.rx.Nil -import com.google.android.ground.rx.annotations.Hot import com.google.android.ground.system.LocationManager import com.google.android.ground.system.PermissionsManager import com.google.android.ground.system.SettingsManager import com.google.android.ground.ui.common.BaseMapViewModel import com.google.android.ground.ui.common.MapConfig +import com.google.android.ground.ui.common.Navigator import com.google.android.ground.ui.map.MapType -import dagger.hilt.android.qualifiers.ApplicationContext -import io.reactivex.BackpressureStrategy -import io.reactivex.Flowable -import io.reactivex.subjects.PublishSubject -import java.lang.ref.WeakReference +import com.google.android.ground.util.toMb +import com.google.android.ground.util.toMbString import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.await import timber.log.Timber /** @@ -48,8 +46,8 @@ import timber.log.Timber class OfflineAreaViewerViewModel @Inject constructor( - offlineAreaRepository: OfflineAreaRepository, - @ApplicationContext context: Context, + private val offlineAreaRepository: OfflineAreaRepository, + private val navigator: Navigator, locationManager: LocationManager, mapStateRepository: MapStateRepository, settingsManager: SettingsManager, @@ -69,51 +67,42 @@ constructor( ioDispatcher ) { - private val fragmentArgs: @Hot(replays = true) PublishSubject = - PublishSubject.create() - private val removeAreaClicks: @Hot PublishSubject = PublishSubject.create() - - private val context: WeakReference - /** Returns the offline area associated with this view model. */ - @JvmField val offlineArea: LiveData - @JvmField var areaName: LiveData + val area = MutableLiveData() + val areaName = area.map { it.name } + val areaSize = MutableLiveData() + val progressOverlayVisible = MutableLiveData() - private var offlineAreaId: String? = null + private lateinit var offlineAreaId: String override val mapConfig: MapConfig get() = super.mapConfig.copy( - showOfflineTileOverlays = false, - overrideMapType = MapType.ROAD, + showOfflineTileOverlays = true, + overrideMapType = MapType.TERRAIN, disableGestures = true ) - init { - this.context = WeakReference(context) - - // We only need to convert this single to a flowable in order to use it with LiveData. - // It still only contains a single offline area returned by getOfflineArea. - val offlineAreaItemAsFlowable: @Hot Flowable = - this.fragmentArgs - .map(OfflineAreaViewerFragmentArgs::getOfflineAreaId) - .flatMapSingle(offlineAreaRepository::getOfflineArea) - .doOnError { Timber.e(it, "Couldn't render area %s", offlineAreaId) } - .toFlowable(BackpressureStrategy.LATEST) - - areaName = offlineAreaItemAsFlowable.map(OfflineArea::name).toLiveData() - offlineArea = offlineAreaItemAsFlowable.toLiveData() + /** Initialize the view model with the given arguments. */ + fun initialize(args: OfflineAreaViewerFragmentArgs) { + offlineAreaId = args.offlineAreaId + viewModelScope.launch(ioDispatcher) { + val thisArea = offlineAreaRepository.getOfflineArea(offlineAreaId).await() + area.postValue(thisArea) + areaSize.postValue(offlineAreaRepository.sizeOnDevice(thisArea).toMb().toMbString()) + } } - /** Gets a single offline area by the id passed to the OfflineAreaViewerFragment's arguments. */ - fun loadOfflineArea(args: OfflineAreaViewerFragmentArgs) { - fragmentArgs.onNext(args) - offlineAreaId = args.offlineAreaId + /** Deletes the area associated with this view model. */ + fun onRemoveButtonClick() { + progressOverlayVisible.value = true + viewModelScope.launch(ioDispatcher) { removeOfflineArea(area.value) } } - /** Deletes the area associated with this viewmodel. */ - fun removeArea() { - Timber.d("Removing offline area %s", offlineArea.value) - removeAreaClicks.onNext(Nil.NIL) + private suspend fun removeOfflineArea(deletedArea: OfflineArea?) { + if (deletedArea == null) return + Timber.d("Removing offline area ${deletedArea.name}") + offlineAreaRepository.removeFromDevice(deletedArea) + navigator.navigateUp() } } diff --git a/ground/src/main/java/com/google/android/ground/util/FileExt.kt b/ground/src/main/java/com/google/android/ground/util/FileExt.kt new file mode 100644 index 0000000000..dded2b799c --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/util/FileExt.kt @@ -0,0 +1,41 @@ +/* + * 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.util + +import java.io.File +import kotlin.math.ceil + +typealias ByteCount = Int + +typealias MegabyteCount = Float + +/** Return true iff the file is non-null and contains other files. */ +fun File?.isEmpty() = this?.listFiles().isNullOrEmpty() + +/** Deletes the file if itt is non-null and doesn't contain other files. */ +fun File.deleteIfEmpty() { + if (isEmpty()) delete() +} + +/** Returns the byte count as an equivalent megabyte count. */ +fun ByteCount.toMb(): MegabyteCount = this / (1024f * 1024f) + +/** + * Returns the number of megabytes a string, replacing smaller sizes with "<1" and rounding up + * others. + */ +fun MegabyteCount.toMbString(): String = if (this < 1) "<1" else ceil(this).toInt().toString() diff --git a/ground/src/main/java/com/google/android/ground/util/RangeExt.kt b/ground/src/main/java/com/google/android/ground/util/RangeExt.kt new file mode 100644 index 0000000000..f10d207346 --- /dev/null +++ b/ground/src/main/java/com/google/android/ground/util/RangeExt.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.util + +import kotlin.math.max +import kotlin.math.min + +/** + * Returns a range containing the smallest and largest among all values produced by the selector + * function applied to each element in the iterable collection. Returns [IntRange.EMPTY] if the + * iterator contains no elements. + */ +inline fun Iterable.rangeOf(selector: (T) -> Int): IntRange = + if (!iterator().hasNext()) IntRange.EMPTY + else + map(selector) + .map { IntRange(it, it) } + .reduce { out, el -> IntRange(min(out.first, el.first), max(out.last, el.last)) } diff --git a/ground/src/main/res/layout/offline_area_selector_frag.xml b/ground/src/main/res/layout/offline_area_selector_frag.xml index 2eba33de71..11ea47bf57 100644 --- a/ground/src/main/res/layout/offline_area_selector_frag.xml +++ b/ground/src/main/res/layout/offline_area_selector_frag.xml @@ -70,7 +70,7 @@ android:layout_width="match_parent" android:layout_height="24dp" android:alpha="0.4" - android:background="@color/blackMapOverlay" + android:background="@color/blackOverlay" app:layout_constraintTop_toBottomOf="@+id/offline_area_selector_toolbar" /> @@ -88,7 +88,7 @@ android:layout_width="24dp" android:layout_height="0dp" android:alpha="0.4" - android:background="@color/blackMapOverlay" + android:background="@color/blackOverlay" app:layout_constraintBottom_toTopOf="@id/bottom_mask" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@id/top_mask" /> @@ -113,7 +113,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:alpha="0.4" - android:background="@color/blackMapOverlay" /> + android:background="@color/blackOverlay" /> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> - - - + + android:layout_height="match_parent" + android:background="?attr/colorSurface" + android:fitsSystemWindows="true" + android:orientation="vertical"> + + + + + + + + + + + + +