From 9fa54545cec5945d0fd31b0123035a995abc0358 Mon Sep 17 00:00:00 2001 From: Gino Miceli Date: Wed, 20 Sep 2023 18:20:39 -0400 Subject: [PATCH 01/34] Minor refactor --- .../android/ground/repository/OfflineAreaRepository.kt | 7 ++++--- .../com/google/android/ground/ui/map/gms/mog/MogClient.kt | 8 ++++---- .../google/android/ground/ui/map/gms/mog/MogCollection.kt | 7 ++----- .../com/google/android/ground/ui/map/gms/mog/MogSource.kt | 6 ++++++ 4 files changed, 16 insertions(+), 12 deletions(-) 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..523e500884 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 @@ -131,12 +131,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,7 +148,7 @@ constructor( suspend fun hasHiResImagery(bounds: Bounds): Boolean { val client = getMogClient() - val maxZoom = client.collection.maxZoom + val maxZoom = client.collection.sources.maxZoom() return client.buildTilesRequests(bounds.toGoogleMapsObject(), maxZoom..maxZoom).isNotEmpty() } 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..71b649578f 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 @@ -44,14 +44,14 @@ class MogClient(val collection: MogCollection) { * specified [tileBounds] and [zoomRange]s. * * @param tileBounds the bounds used to constrain which tiles are retrieved. Only requests for - * tiles within or overlapping these bounds are returned. + * tiles within or overlapping these bounds are returned. * @param zoomRange the min. and max. zoom levels for which tile requests should be returned. - * Defaults to all available zoom levels in the collection ([MogCollection.minZoom] to - * [MogCollection.maxZoom]). + * Defaults to all available zoom levels in the collection ([MogCollection.minZoom] to + * [MogCollection.maxZoom]). */ suspend fun buildTilesRequests( tileBounds: LatLngBounds, - zoomRange: IntRange = IntRange(collection.minZoom, collection.maxZoom) + zoomRange: IntRange = collection.sources.zoomRange() ) = zoomRange .flatMap { zoom -> buildTileRequests(tileBounds, zoom) } 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()) From e9f939af63e8010f0f6f7d05e1f67e8b979ff342 Mon Sep 17 00:00:00 2001 From: Gino Miceli Date: Wed, 20 Sep 2023 18:40:16 -0400 Subject: [PATCH 02/34] WIP: Get size of offline imagery on disk --- .../ground/model/imagery/OfflineArea.kt | 9 +++++++- .../repository/OfflineAreaRepository.kt | 21 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) 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..13178264df 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 @@ -18,7 +18,14 @@ package com.google.android.ground.model.imagery import com.google.android.ground.ui.map.Bounds /** 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 +) { enum class State { PENDING, IN_PROGRESS, 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 523e500884..032d896a15 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 @@ -50,14 +50,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 ) ) } @@ -91,7 +92,8 @@ constructor( emit(Pair(bytesDownloaded, totalBytes)) } if (bytesDownloaded > 0) { - addOfflineArea(bounds) + // TODO: Get range of actual tiles. + addOfflineArea(bounds, 0..14) } } @@ -157,4 +159,17 @@ constructor( val requests = client.buildTilesRequests(bounds.toGoogleMapsObject()) return requests.sumOf { it.totalBytes } } + + suspend fun actualSizeOnDisk(offlineArea: OfflineArea): Int = + offlineArea.zoomRange.sumOf { zoomLevel -> + // TODO: Why doesn't withinBounds() accept Bounds directly? + TileCoordinates.withinBounds(offlineArea.bounds.toGoogleMapsObject(), zoomLevel).sumOf { + actualSizeOnDisk(it) + } + } + + suspend fun actualSizeOnDisk(tileCoordinates: TileCoordinates): Int { + // TODO: Read size of file. + return 0 + } } From ae86b792c5e64aa8568ff80d95ef7a2a53c4a91b Mon Sep 17 00:00:00 2001 From: Gino Miceli Date: Wed, 20 Sep 2023 21:53:13 -0400 Subject: [PATCH 03/34] Refactor tile download path builder --- .../android/ground/ui/map/gms/mog/MogTileDownloader.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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..1c65a671c3 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,10 @@ 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()) + // We know parent dir won't be null because it's defined in the extension file in this file. + 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 +45,5 @@ class MogTileDownloader(private val client: MogClient, private val outputBasePat } } } + +fun TileCoordinates.getTilePath() = "$zoom/$x/$y.jpg" From ec0c506988e2aa8500c31fa4109c5430ae4daf81 Mon Sep 17 00:00:00 2001 From: Gino Miceli Date: Wed, 20 Sep 2023 21:55:39 -0400 Subject: [PATCH 04/34] Calculate size on disk --- .../android/ground/repository/OfflineAreaRepository.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 032d896a15..5863721c87 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 @@ -168,8 +168,6 @@ constructor( } } - suspend fun actualSizeOnDisk(tileCoordinates: TileCoordinates): Int { - // TODO: Read size of file. - return 0 - } + suspend fun actualSizeOnDisk(tileCoordinates: TileCoordinates): Int = + File(getLocalTileSourcePath(), tileCoordinates.getTilePath()).length() as Int } From b9ca610944dc808004770c0b06c7baa8cdec93af Mon Sep 17 00:00:00 2001 From: Gino Miceli Date: Mon, 25 Sep 2023 21:54:10 -0400 Subject: [PATCH 05/34] Calculate size on disk --- .../persistence/local/room/converter/ConverterExt.kt | 3 ++- .../ground/repository/OfflineAreaRepository.kt | 5 +---- .../ground/ui/offlineareas/OfflineAreasViewModel.kt | 11 +++++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) 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 498f167d9b..c9a864db18 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 @@ -252,7 +252,8 @@ 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) + // TODO: Deserialize. + return OfflineArea(this.id, this.state.toModelObject(), bounds, this.name, 0..14) } fun Option.toLocalDataStoreObject(taskId: String) = 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 5863721c87..010af16c71 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 @@ -164,10 +164,7 @@ constructor( offlineArea.zoomRange.sumOf { zoomLevel -> // TODO: Why doesn't withinBounds() accept Bounds directly? TileCoordinates.withinBounds(offlineArea.bounds.toGoogleMapsObject(), zoomLevel).sumOf { - actualSizeOnDisk(it) + File(getLocalTileSourcePath(), it.getTilePath()).length().toInt() } } - - suspend fun actualSizeOnDisk(tileCoordinates: TileCoordinates): Int = - File(getLocalTileSourcePath(), tileCoordinates.getTilePath()).length() as Int } 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..6986e85749 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 @@ -18,11 +18,13 @@ package com.google.android.ground.ui.offlineareas import android.view.View import androidx.lifecycle.LiveData import androidx.lifecycle.toLiveData +import androidx.lifecycle.viewModelScope import com.google.android.ground.model.imagery.OfflineArea import com.google.android.ground.repository.OfflineAreaRepository import com.google.android.ground.ui.common.AbstractViewModel import com.google.android.ground.ui.common.Navigator import javax.inject.Inject +import kotlinx.coroutines.launch import timber.log.Timber /** @@ -52,8 +54,17 @@ internal constructor( val offlineAreas = offlineAreaRepository .offlineAreasOnceAndStream() + .doOnNext { + viewModelScope.launch { + it.forEach { area -> + val size = offlineAreaRepository.actualSizeOnDisk(area) + Timber.e("!!! ${area.name} Size: $size") + } + } + } .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() From 5c8fa311b5b180941e2890ab8922e9ec78c82727 Mon Sep 17 00:00:00 2001 From: Gino Miceli Date: Thu, 5 Oct 2023 17:34:51 -0400 Subject: [PATCH 06/34] Implement delete offline maps --- .../ground/model/imagery/OfflineArea.kt | 7 + .../local/room/dao/OfflineAreaDao.kt | 2 + .../local/room/stores/RoomOfflineAreaStore.kt | 11 +- .../local/stores/LocalOfflineAreaStore.kt | 2 +- .../repository/OfflineAreaRepository.kt | 27 ++- .../ui/offlineareas/OfflineAreasViewModel.kt | 10 -- .../selector/OfflineAreaSelectorViewModel.kt | 10 +- .../viewer/OfflineAreaViewerFragment.kt | 28 +-- .../viewer/OfflineAreaViewerViewModel.kt | 75 ++++---- .../res/layout/offline_area_selector_frag.xml | 8 +- .../res/layout/offline_area_viewer_frag.xml | 163 +++++++++++------- ground/src/main/res/values/colors.xml | 2 +- ground/src/main/res/values/strings.xml | 3 +- ground/src/main/res/values/styles.xml | 16 +- 14 files changed, 203 insertions(+), 161 deletions(-) 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 13178264df..80d9146a63 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,6 +16,8 @@ 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 +import com.google.android.ground.ui.map.gms.toGoogleMapsObject /** An area is a contiguous set of tiles that task a geodesic rectangle. */ data class OfflineArea( @@ -26,10 +28,15 @@ data class OfflineArea( /** The range of zoom levels downloaded. */ val zoomRange: IntRange ) { + // TODO: Why doesn't withinBounds() accept Bounds directly? + val tiles + get() = zoomRange.flatMap { TileCoordinates.withinBounds(bounds.toGoogleMapsObject(), it) } + enum class State { PENDING, IN_PROGRESS, DOWNLOADED, FAILED } + } 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/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..b0166a824e 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 @@ -32,7 +32,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 010af16c71..a18aeb7741 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 @@ -32,6 +32,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.* import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirst /** * Corners of the viewport are scaled by this value when determining the name of downloaded areas. @@ -161,10 +162,26 @@ constructor( } suspend fun actualSizeOnDisk(offlineArea: OfflineArea): Int = - offlineArea.zoomRange.sumOf { zoomLevel -> - // TODO: Why doesn't withinBounds() accept Bounds directly? - TileCoordinates.withinBounds(offlineArea.bounds.toGoogleMapsObject(), zoomLevel).sumOf { - File(getLocalTileSourcePath(), it.getTilePath()).length().toInt() - } + offlineArea.tiles.sumOf { File(getLocalTileSourcePath(), it.getTilePath()).length().toInt() } + + suspend fun removeFromDevice(offlineArea: OfflineArea) { + val tilesInSelectedArea = offlineArea.tiles + 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 { + val tilePath = File(tileSourcePath, it.getTilePath()) + tilePath.delete() + tilePath.parentFile.deleteIfEmpty() + tilePath.parentFile?.parentFile.deleteIfEmpty() } + } +} + +private fun File?.isEmpty() = this?.listFiles().isNullOrEmpty() + +private fun File?.deleteIfEmpty() { + if (isEmpty()) this?.delete() } 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 6986e85749..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 @@ -18,13 +18,11 @@ package com.google.android.ground.ui.offlineareas import android.view.View import androidx.lifecycle.LiveData import androidx.lifecycle.toLiveData -import androidx.lifecycle.viewModelScope import com.google.android.ground.model.imagery.OfflineArea import com.google.android.ground.repository.OfflineAreaRepository import com.google.android.ground.ui.common.AbstractViewModel import com.google.android.ground.ui.common.Navigator import javax.inject.Inject -import kotlinx.coroutines.launch import timber.log.Timber /** @@ -54,14 +52,6 @@ internal constructor( val offlineAreas = offlineAreaRepository .offlineAreasOnceAndStream() - .doOnNext { - viewModelScope.launch { - it.forEach { area -> - val size = offlineAreaRepository.actualSizeOnDisk(area) - Timber.e("!!! ${area.name} Size: $size") - } - } - } .doOnError { Timber.e(it, "Unexpected error loading offline areas from the local db") } .onErrorReturnItem(listOf()) 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..575dde5e2e 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 @@ -36,7 +36,6 @@ 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 javax.inject.Inject -import kotlin.math.ceil import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch @@ -51,7 +50,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 +71,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 @@ -153,7 +153,7 @@ internal constructor( } private fun onDownloadableAreaSelected(sizeInMb: Float) { - val sizeString = if (sizeInMb < 1f) "<1" else ceil(sizeInMb).toInt().toString() + val sizeString = if (sizeInMb < 0.1f) "<0.1" else "%.1f".format(sizeInMb) sizeOnDisk.postValue(sizeString) 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..2a8f00c958 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,26 @@ */ 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 javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.await import timber.log.Timber /** @@ -48,8 +44,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 +65,40 @@ 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 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() - } - - /** Gets a single offline area by the id passed to the OfflineAreaViewerFragment's arguments. */ - fun loadOfflineArea(args: OfflineAreaViewerFragmentArgs) { - fragmentArgs.onNext(args) + /** 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.actualSizeOnDisk(thisArea) / (1024f * 1024f))) + } } - /** Deletes the area associated with this viewmodel. */ - fun removeArea() { - Timber.d("Removing offline area %s", offlineArea.value) - removeAreaClicks.onNext(Nil.NIL) + /** Deletes the area associated with this view model. */ + fun onRemoveButtonClick() { + progressOverlayVisible.value = true + viewModelScope.launch(ioDispatcher) { + Timber.d("Removing offline area %s", area.value) + val area = area.value ?: return@launch + offlineAreaRepository.removeFromDevice(area) + navigator.navigateUp() + } } } 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"> + + + + + + + + + + + + + +