From 2c7ef558cac1c9b86ed6add51336d716b795d4d7 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 8 Apr 2026 16:40:06 +0200 Subject: [PATCH 1/8] fix crash on OfflineAreaSelectorViewModel flow collection --- .../ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt index 06674bb865..6e83b03202 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt @@ -51,7 +51,6 @@ private const val MIN_DOWNLOAD_ZOOM_LEVEL = 9 private const val MAX_AREA_DOWNLOAD_SIZE_MB = 50 /** States and behaviors of Map UI used to select areas for download and viewing offline. */ -@SharedViewModel class OfflineAreaSelectorViewModel @Inject internal constructor( From 2eed56e2352f695ebfa73b2a96f519c3377ad3ac Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 8 Apr 2026 16:40:27 +0200 Subject: [PATCH 2/8] clean up flow collections in the fragment --- .../selector/OfflineAreaSelectorFragment.kt | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt index 66173098eb..fad45c4b8e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt @@ -95,34 +95,57 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() { } private fun setupObservers() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isDownloadProgressVisible.observe(viewLifecycleOwner) { - showDownloadProgressDialog(it) - } - viewModel.isFailure.observe(viewLifecycleOwner) { - if (it) { - Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG).show() - } + viewModel.isDownloadProgressVisible.observe(viewLifecycleOwner) { + showDownloadProgressDialog(it) + } + viewModel.isFailure.observe(viewLifecycleOwner) { + if (it) { + Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG).show() } } - lifecycleScope.launch { - viewModel.navigate.collect { - when (it) { - is UiState.OfflineAreaBackToHomeScreen -> { - findNavController() - .navigate(OfflineAreaSelectorFragmentDirections.offlineAreaBackToHomescreen()) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.navigate.collect { + when (it) { + is UiState.OfflineAreaBackToHomeScreen -> { + findNavController() + .navigate(OfflineAreaSelectorFragmentDirections.offlineAreaBackToHomescreen()) + } + + is UiState.Up -> { + findNavController().navigateUp() + } + } } - is UiState.Up -> { - findNavController().navigateUp() + } + launch { + viewModel.networkUnavailableEvent.collect { + popups.ErrorPopup().show(R.string.connect_to_download_message) + } + } + launch { + viewModel.bottomTextState.collect { + binding.bottomText.text = + when (it) { + is BottomTextState.AreaSize -> + resources.getString(R.string.selected_offline_area_size, it.size) + BottomTextState.AreaTooLarge -> + resources.getString(R.string.selected_offline_area_too_large) + BottomTextState.Loading -> + resources.getString( + R.string.selected_offline_area_size, + resources.getString(R.string.offline_area_size_loading_symbol), + ) + BottomTextState.NetworkError -> + resources.getString(R.string.connect_to_download_message) + BottomTextState.NoImageryAvailable -> + resources.getString(R.string.no_imagery_available_for_area) + null -> "" + } } } - } - } - - lifecycleScope.launch { - viewModel.networkUnavailableEvent.collect { - popups.ErrorPopup().show(R.string.connect_to_download_message) } } @@ -130,29 +153,6 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() { binding.downloadButton.isEnabled = it binding.downloadButton.isClickable = it } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.bottomTextState.collect { - binding.bottomText.text = - when (it) { - is BottomTextState.AreaSize -> - resources.getString(R.string.selected_offline_area_size, it.size) - BottomTextState.AreaTooLarge -> - resources.getString(R.string.selected_offline_area_too_large) - BottomTextState.Loading -> - resources.getString( - R.string.selected_offline_area_size, - resources.getString(R.string.offline_area_size_loading_symbol), - ) - BottomTextState.NetworkError -> - resources.getString(R.string.connect_to_download_message) - BottomTextState.NoImageryAvailable -> - resources.getString(R.string.no_imagery_available_for_area) - null -> "" - } - } - } - } } override fun getMapConfig(): MapConfig = From 3f01f362e931d196bc008e3aa6f26834b66c9e93 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 8 Apr 2026 17:17:41 +0200 Subject: [PATCH 3/8] remove unused import --- .../ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt index 6e83b03202..747427cb0e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt @@ -38,7 +38,6 @@ import org.groundplatform.android.system.NetworkManager import org.groundplatform.android.system.PermissionsManager import org.groundplatform.android.system.SettingsManager import org.groundplatform.android.ui.common.BaseMapViewModel -import org.groundplatform.android.ui.common.SharedViewModel import org.groundplatform.android.ui.offlineareas.selector.model.BottomTextState import org.groundplatform.android.ui.offlineareas.selector.model.UiState import org.groundplatform.android.util.toMb From 85ab5d9a6a8aac5d81cf00823a5bd98c08cf7c82 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 10 Apr 2026 15:19:24 +0200 Subject: [PATCH 4/8] refactor Offline area selector ui state --- .../selector/OfflineAreaSelectorFragment.kt | 116 +++++++++--------- .../selector/OfflineAreaSelectorViewModel.kt | 73 +++++------ .../selector/model/BottomTextState.kt | 29 ----- ...UiState.kt => OfflineAreaSelectorEvent.kt} | 10 +- .../model/OfflineAreaSelectorState.kt | 49 ++++++++ 5 files changed, 148 insertions(+), 129 deletions(-) delete mode 100644 app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/BottomTextState.kt rename app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/{UiState.kt => OfflineAreaSelectorEvent.kt} (69%) create mode 100644 app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt index fad45c4b8e..29b8242a4a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragment.kt @@ -22,9 +22,6 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -44,8 +41,8 @@ import org.groundplatform.android.ui.common.MapConfig import org.groundplatform.android.ui.components.MapFloatingActionButton import org.groundplatform.android.ui.home.mapcontainer.HomeScreenMapContainerViewModel import org.groundplatform.android.ui.map.MapFragment -import org.groundplatform.android.ui.offlineareas.selector.model.BottomTextState -import org.groundplatform.android.ui.offlineareas.selector.model.UiState +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorEvent +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent @@ -91,68 +88,40 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() { } binding.downloadButton.setOnClickListener { viewModel.onDownloadClick() } binding.cancelButton.setOnClickListener { viewModel.onCancelClick() } + setupDownloadProgressDialog() setupObservers() } private fun setupObservers() { - viewModel.isDownloadProgressVisible.observe(viewLifecycleOwner) { - showDownloadProgressDialog(it) - } - viewModel.isFailure.observe(viewLifecycleOwner) { - if (it) { - Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG).show() - } - } - viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiState.collect { updateUi(it) } } + launch { - viewModel.navigate.collect { + viewModel.uiEvent.collect { when (it) { - is UiState.OfflineAreaBackToHomeScreen -> { + is OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen -> { findNavController() .navigate(OfflineAreaSelectorFragmentDirections.offlineAreaBackToHomescreen()) } - is UiState.Up -> { + is OfflineAreaSelectorEvent.NavigateUp -> { findNavController().navigateUp() } - } - } - } - launch { - viewModel.networkUnavailableEvent.collect { - popups.ErrorPopup().show(R.string.connect_to_download_message) - } - } - launch { - viewModel.bottomTextState.collect { - binding.bottomText.text = - when (it) { - is BottomTextState.AreaSize -> - resources.getString(R.string.selected_offline_area_size, it.size) - BottomTextState.AreaTooLarge -> - resources.getString(R.string.selected_offline_area_too_large) - BottomTextState.Loading -> - resources.getString( - R.string.selected_offline_area_size, - resources.getString(R.string.offline_area_size_loading_symbol), - ) - BottomTextState.NetworkError -> - resources.getString(R.string.connect_to_download_message) - BottomTextState.NoImageryAvailable -> - resources.getString(R.string.no_imagery_available_for_area) - null -> "" + + OfflineAreaSelectorEvent.NetworkUnavailable -> { + popups.ErrorPopup().show(R.string.connect_to_download_message) + } + + OfflineAreaSelectorEvent.DownloadError -> { + Toast.makeText(context, R.string.offline_area_download_error, Toast.LENGTH_LONG) + .show() } + } } } } } - - viewModel.downloadButtonEnabled.observe(viewLifecycleOwner) { - binding.downloadButton.isEnabled = it - binding.downloadButton.isClickable = it - } } override fun getMapConfig(): MapConfig = @@ -173,20 +142,45 @@ class OfflineAreaSelectorFragment : AbstractMapContainerFragment() { override fun getMapViewModel(): BaseMapViewModel = viewModel - private fun showDownloadProgressDialog(isVisible: Boolean) { - renderComposableDialog { - val openAlertDialog = remember { mutableStateOf(isVisible) } - val progress = viewModel.downloadProgress.observeAsState(0f) - when { - openAlertDialog.value -> { - DownloadProgressDialog( - progress = progress.value, - onDismiss = { - openAlertDialog.value = false - viewModel.stopDownloading() - }, + private fun updateUi(state: OfflineAreaSelectorState) { + binding.bottomText.text = + when (state.bottomTextState) { + is OfflineAreaSelectorState.BottomTextState.AreaSize -> + resources.getString(R.string.selected_offline_area_size, state.bottomTextState.size) + + OfflineAreaSelectorState.BottomTextState.AreaTooLarge -> + resources.getString(R.string.selected_offline_area_too_large) + + OfflineAreaSelectorState.BottomTextState.Loading -> + resources.getString( + R.string.selected_offline_area_size, + resources.getString(R.string.offline_area_size_loading_symbol), ) - } + + OfflineAreaSelectorState.BottomTextState.NetworkError -> + resources.getString(R.string.connect_to_download_message) + + OfflineAreaSelectorState.BottomTextState.NoImageryAvailable -> + resources.getString(R.string.no_imagery_available_for_area) + + null -> "" + } + + with(binding.downloadButton) { + isEnabled = state.isDownloadButtonEnabled() + isClickable = state.isDownloadButtonEnabled() + } + } + + private fun setupDownloadProgressDialog() { + renderComposableDialog { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val downloadState = state.downloadState + if (downloadState is OfflineAreaSelectorState.DownloadState.InProgress) { + DownloadProgressDialog( + progress = downloadState.progress, + onDismiss = { viewModel.stopDownloading() }, + ) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt index 747427cb0e..883ab61e15 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModel.kt @@ -15,7 +15,6 @@ */ package org.groundplatform.android.ui.offlineareas.selector -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher @@ -38,8 +37,8 @@ import org.groundplatform.android.system.NetworkManager import org.groundplatform.android.system.PermissionsManager import org.groundplatform.android.system.SettingsManager import org.groundplatform.android.ui.common.BaseMapViewModel -import org.groundplatform.android.ui.offlineareas.selector.model.BottomTextState -import org.groundplatform.android.ui.offlineareas.selector.model.UiState +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorEvent +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState import org.groundplatform.android.util.toMb import org.groundplatform.android.util.toMbString import org.groundplatform.domain.model.map.Bounds @@ -76,26 +75,18 @@ internal constructor( val remoteTileSource: TileSource = RemoteMogTileSource private var viewport: Bounds? = null - val isDownloadProgressVisible = MutableLiveData(false) - val downloadProgress = MutableLiveData(0f) - private val _bottomTextState = MutableStateFlow(null) - val bottomTextState: StateFlow = _bottomTextState + private val _uiState = MutableStateFlow(OfflineAreaSelectorState()) + val uiState: StateFlow = _uiState - val downloadButtonEnabled = MutableLiveData(false) - val isFailure = MutableLiveData(false) - - private val _navigate = MutableSharedFlow(replay = 0) - val navigate = _navigate.asSharedFlow() - - private val _networkUnavailableEvent = MutableSharedFlow() - val networkUnavailableEvent = _networkUnavailableEvent.asSharedFlow() + private val _uiEvent = MutableSharedFlow(replay = 0) + val uiEvent = _uiEvent.asSharedFlow() var downloadJob: Job? = null fun onDownloadClick() { if (!networkManager.isNetworkConnected()) { - viewModelScope.launch { _networkUnavailableEvent.emit(Unit) } + viewModelScope.launch { _uiEvent.emit(OfflineAreaSelectorEvent.NetworkUnavailable) } return } @@ -104,22 +95,24 @@ internal constructor( return } - isDownloadProgressVisible.value = true - downloadProgress.value = 0f + _uiState.value = + _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.InProgress(0f)) downloadJob = viewModelScope.launch(ioDispatcher) { offlineAreaRepository .downloadTiles(viewport!!) .catch { - isFailure.postValue(true) - isDownloadProgressVisible.postValue(false) + _uiState.value = + _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) + _uiEvent.emit(OfflineAreaSelectorEvent.DownloadError) Timber.d("Download Stopped by $it ") } .collect { (bytesDownloaded, totalBytes) -> updateDownloadProgress(bytesDownloaded, totalBytes) } - isDownloadProgressVisible.postValue(false) - _navigate.emit(UiState.OfflineAreaBackToHomeScreen) + _uiState.value = + _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) + _uiEvent.emit(OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen) } } @@ -130,22 +123,25 @@ internal constructor( } else { 0f } - downloadProgress.postValue(progressValue) + _uiState.value = + _uiState.value.copy( + downloadState = OfflineAreaSelectorState.DownloadState.InProgress(progressValue) + ) } fun onCancelClick() { - viewModelScope.launch { _navigate.emit(UiState.Up) } + viewModelScope.launch { _uiEvent.emit(OfflineAreaSelectorEvent.NavigateUp) } } fun stopDownloading() { downloadJob?.cancel() downloadJob = null - isDownloadProgressVisible.postValue(false) + _uiState.value = + _uiState.value.copy(downloadState = OfflineAreaSelectorState.DownloadState.Idle) } override fun onMapDragged() { - downloadButtonEnabled.postValue(false) - _bottomTextState.value = null + _uiState.value = _uiState.value.copy(bottomTextState = null) super.onMapDragged() } @@ -176,7 +172,8 @@ internal constructor( onUnavailableAreaSelected() return } - _bottomTextState.value = BottomTextState.Loading + _uiState.value = + _uiState.value.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.Loading) offlineAreaRepository .estimateSizeOnDisk(bounds) @@ -195,22 +192,26 @@ internal constructor( } private fun onUpdateDownloadSizeError() { - _bottomTextState.value = BottomTextState.NetworkError - downloadButtonEnabled.postValue(false) + _uiState.value = + _uiState.value.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.NetworkError) } private fun onUnavailableAreaSelected() { - _bottomTextState.value = BottomTextState.NoImageryAvailable - downloadButtonEnabled.postValue(false) + _uiState.value = + _uiState.value.copy( + bottomTextState = OfflineAreaSelectorState.BottomTextState.NoImageryAvailable + ) } private fun onDownloadableAreaSelected(sizeInMb: Float) { - _bottomTextState.value = BottomTextState.AreaSize(sizeInMb.toMbString()) - downloadButtonEnabled.postValue(true) + _uiState.value = + _uiState.value.copy( + bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaSize(sizeInMb.toMbString()) + ) } private fun onLargeAreaSelected() { - _bottomTextState.value = BottomTextState.AreaTooLarge - downloadButtonEnabled.postValue(false) + _uiState.value = + _uiState.value.copy(bottomTextState = OfflineAreaSelectorState.BottomTextState.AreaTooLarge) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/BottomTextState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/BottomTextState.kt deleted file mode 100644 index 381f7a4e37..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/BottomTextState.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2026 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 org.groundplatform.android.ui.offlineareas.selector.model - -/** Represents the state of the bottom text in the offline area selector. */ -sealed class BottomTextState { - object Loading : BottomTextState() - - data class AreaSize(val size: String) : BottomTextState() - - object NoImageryAvailable : BottomTextState() - - object AreaTooLarge : BottomTextState() - - object NetworkError : BottomTextState() -} diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/UiState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorEvent.kt similarity index 69% rename from app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/UiState.kt rename to app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorEvent.kt index 240885cf38..817c87ba5b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/UiState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorEvent.kt @@ -15,9 +15,13 @@ */ package org.groundplatform.android.ui.offlineareas.selector.model -sealed class UiState { +sealed class OfflineAreaSelectorEvent { - data object OfflineAreaBackToHomeScreen : UiState() + data object NavigateOfflineAreaBackToHomeScreen : OfflineAreaSelectorEvent() - data object Up : UiState() + data object NavigateUp : OfflineAreaSelectorEvent() + + data object NetworkUnavailable : OfflineAreaSelectorEvent() + + data object DownloadError : OfflineAreaSelectorEvent() } diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt new file mode 100644 index 0000000000..8e56ca325f --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 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 org.groundplatform.android.ui.offlineareas.selector.model + +/** + * Represents the complete UI state of the Offline Area Selector screen. + * + * @property bottomTextState Describes what should be displayed in the bottom text area. This may be + * null when no message should be shown. + * @property downloadState Represents the current state of the download operation, whether a + * download is in progress and its progress. + */ +data class OfflineAreaSelectorState( + val bottomTextState: BottomTextState? = null, + val downloadState: DownloadState = DownloadState.Idle, +) { + sealed class BottomTextState { + object Loading : BottomTextState() + + data class AreaSize(val size: String) : BottomTextState() + + object NoImageryAvailable : BottomTextState() + + object AreaTooLarge : BottomTextState() + + object NetworkError : BottomTextState() + } + + sealed class DownloadState { + data object Idle : DownloadState() + + data class InProgress(val progress: Float) : DownloadState() + } + + fun isDownloadButtonEnabled(): Boolean = bottomTextState is BottomTextState.AreaSize +} From a9164b42e2043a7d99158f3921d4ff6b7eba36e6 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 10 Apr 2026 15:21:48 +0200 Subject: [PATCH 5/8] update tests --- .../selector/DownloadProgressDialogTest.kt | 18 +- .../OfflineAreaSelectorFragmentTest.kt | 40 +-- .../OfflineAreaSelectorViewModelTest.kt | 237 +++++++++++++++--- 3 files changed, 235 insertions(+), 60 deletions(-) diff --git a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialogTest.kt b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialogTest.kt index e1cb6bd332..69f21c5142 100644 --- a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialogTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/DownloadProgressDialogTest.kt @@ -21,9 +21,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import dagger.hilt.android.testing.HiltAndroidTest -import javax.inject.Inject import kotlin.test.Test -import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.R import org.groundplatform.android.getString import org.junit.Assert.assertTrue @@ -33,15 +31,13 @@ import org.robolectric.RobolectricTestRunner @HiltAndroidTest @RunWith(RobolectricTestRunner::class) -class DownloadProgressDialogTest : BaseHiltTest() { +class DownloadProgressDialogTest { @get:Rule val composeTestRule = createComposeRule() - @Inject lateinit var viewModel: OfflineAreaSelectorViewModel - @Test fun `DownloadProgressDialog displays title correctly`() { - composeTestRule.setContent { DownloadProgressDialog(viewModel.downloadProgress.value!!, {}) } + composeTestRule.setContent { DownloadProgressDialog(0f, {}) } composeTestRule .onNodeWithText(getString(R.string.offline_map_imagery_download_progress_dialog_title, 0)) @@ -50,7 +46,7 @@ class DownloadProgressDialogTest : BaseHiltTest() { @Test fun `DownloadProgressDialog displays correct message`() { - composeTestRule.setContent { DownloadProgressDialog(viewModel.downloadProgress.value!!, {}) } + composeTestRule.setContent { DownloadProgressDialog(0f, {}) } composeTestRule .onNodeWithText(getString(R.string.offline_map_imagery_download_progress_dialog_message)) @@ -61,9 +57,7 @@ class DownloadProgressDialogTest : BaseHiltTest() { fun `DownloadProgressDialog calls onDismiss when dismiss button is clicked`() { var isDismissed = false - composeTestRule.setContent { - DownloadProgressDialog(viewModel.downloadProgress.value!!, { isDismissed = true }) - } + composeTestRule.setContent { DownloadProgressDialog(0f, { isDismissed = true }) } composeTestRule.onNodeWithText(getString(R.string.cancel)).performClick() @@ -72,9 +66,7 @@ class DownloadProgressDialogTest : BaseHiltTest() { @Test fun `DownloadProgressDialog displays correct title for progress percentage`() { - viewModel.downloadProgress.value = 0.5f - - composeTestRule.setContent { DownloadProgressDialog(viewModel.downloadProgress.value!!, {}) } + composeTestRule.setContent { DownloadProgressDialog(0.5f, {}) } composeTestRule .onNodeWithText(getString(R.string.offline_map_imagery_download_progress_dialog_title, 50)) diff --git a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt index ff4ceb8ad9..376374a025 100644 --- a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.lifecycle.Observer import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.hasDescendant @@ -29,25 +28,30 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject -import junit.framework.Assert.assertFalse +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.R import org.groundplatform.android.repository.OfflineAreaRepository +import org.groundplatform.android.system.NetworkManager import org.groundplatform.android.testrules.FragmentScenarioRule -import org.junit.Assert.assertNull +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState +import org.hamcrest.CoreMatchers.not import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.Mockito.mock import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @RunWith(RobolectricTestRunner::class) class OfflineAreaSelectorFragmentTest : BaseHiltTest() { @@ -56,6 +60,7 @@ class OfflineAreaSelectorFragmentTest : BaseHiltTest() { @Inject lateinit var viewModel: OfflineAreaSelectorViewModel private val offlineAreaRepository: OfflineAreaRepository = mock() + @BindValue @Mock lateinit var networkManager: NetworkManager @get:Rule val composeTestRule = createAndroidComposeRule() @get:Rule val fragmentScenario = FragmentScenarioRule() @@ -88,30 +93,32 @@ class OfflineAreaSelectorFragmentTest : BaseHiltTest() { ) } + @Test + fun `download button should be disabled and not clickable by default`() { + onView(withId(R.id.download_button)).check(matches(isDisplayed())) + onView(withId(R.id.download_button)).check(matches(not(isEnabled()))) + } + + @Test + fun `bottom text should be empty by default`() { + onView(withId(R.id.bottom_text)).check(matches(withText(""))) + } + // TODO: Complete below test // Issue URL: https://github.com/google/ground-android/issues/3032 @Test fun `stopDownloading cancels active download and updates UI state`() = runWithTestDispatcher { - composeTestRule.setContent { DownloadProgressDialog(viewModel.downloadProgress.value!!, {}) } + composeTestRule.setContent { DownloadProgressDialog(0f, {}) } val progressFlow = MutableSharedFlow>() whenever(offlineAreaRepository.downloadTiles(any())).thenReturn(progressFlow) - val downloadProgressValues = mutableListOf() - val observer = Observer { downloadProgressValues.add(it) } - - viewModel.downloadProgress.observeForever(observer) - viewModel.onDownloadClick() advanceUntilIdle() progressFlow.emit(Pair(50, 100)) advanceUntilIdle() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) - .isDisplayed() - composeTestRule .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) .performClick() @@ -121,10 +128,9 @@ class OfflineAreaSelectorFragmentTest : BaseHiltTest() { .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) .isNotDisplayed() - assertFalse(viewModel.isDownloadProgressVisible.value!!) - assertNull(viewModel.downloadJob) - - viewModel.downloadProgress.removeObserver(observer) + val state = viewModel.uiState.value + assert(state.downloadState is OfflineAreaSelectorState.DownloadState.Idle) + assert(viewModel.downloadJob == null) } // TODO: Write `test test failure case displays toast` diff --git a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModelTest.kt index 4637187016..730436e0b1 100644 --- a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorViewModelTest.kt @@ -15,16 +15,16 @@ */ package org.groundplatform.android.ui.offlineareas.selector +import app.cash.turbine.test import dagger.hilt.android.testing.HiltAndroidTest import java.net.SocketTimeoutException import java.net.UnknownHostException import kotlin.test.assertNull -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.repository.MapStateRepository @@ -34,12 +34,12 @@ import org.groundplatform.android.system.LocationManager import org.groundplatform.android.system.NetworkManager import org.groundplatform.android.system.PermissionsManager import org.groundplatform.android.system.SettingsManager -import org.groundplatform.android.ui.offlineareas.selector.model.BottomTextState +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorEvent +import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState import org.groundplatform.android.util.toMbString import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -68,7 +68,6 @@ class OfflineAreaSelectorViewModelTest : BaseHiltTest() { @Before override fun setUp() { super.setUp() - Dispatchers.setMain(testDispatcher) viewModel = OfflineAreaSelectorViewModel( offlineAreaRepository, @@ -83,88 +82,266 @@ class OfflineAreaSelectorViewModelTest : BaseHiltTest() { ) } - @After - fun tearDown() { - Dispatchers.resetMain() - } + @Test + fun `Initial state should have null bottomText and download button disabled`() = + runWithTestDispatcher { + assertNull(viewModel.uiState.value.bottomTextState) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) + assertEquals( + OfflineAreaSelectorState.DownloadState.Idle, + viewModel.uiState.value.downloadState, + ) + } @Test - fun `Should show download size correctly`() = runTest { + fun `Should show download size correctly`() = runWithTestDispatcher { setupMocks(estimatedSizeOnDisk = Result.success(1024 * 1024 * 5)) viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() - assertEquals(BottomTextState.AreaSize(5.0f.toMbString()), viewModel.bottomTextState.value) - assertEquals(true, viewModel.downloadButtonEnabled.value) + assertEquals( + OfflineAreaSelectorState.BottomTextState.AreaSize(5.0f.toMbString()), + viewModel.uiState.value.bottomTextState, + ) + assertEquals(true, viewModel.uiState.value.isDownloadButtonEnabled()) + } + + @Test + fun `Should show download size for small areas correctly`() = runWithTestDispatcher { + // Less than 1 MB → should display "<1" + setupMocks(estimatedSizeOnDisk = Result.success(500_000)) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + + assertEquals( + OfflineAreaSelectorState.BottomTextState.AreaSize("<1"), + viewModel.uiState.value.bottomTextState, + ) + assertEquals(true, viewModel.uiState.value.isDownloadButtonEnabled()) } @Test - fun `Should show appropriate message when there is no imagery`() = runTest { + fun `Should show appropriate message when there is no imagery`() = runWithTestDispatcher { setupMocks(hasHiResImagery = Result.success(false)) viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() - assertEquals(BottomTextState.NoImageryAvailable, viewModel.bottomTextState.value) - assertEquals(false, viewModel.downloadButtonEnabled.value) + assertEquals( + OfflineAreaSelectorState.BottomTextState.NoImageryAvailable, + viewModel.uiState.value.bottomTextState, + ) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) } @Test fun `Should show appropriate message when there's a network error checking for high res imagery`() = - runTest { + runWithTestDispatcher { setupMocks(hasHiResImagery = Result.failure(SocketTimeoutException("timeout"))) viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() - assertEquals(BottomTextState.NetworkError, viewModel.bottomTextState.value) - assertEquals(false, viewModel.downloadButtonEnabled.value) + assertEquals( + OfflineAreaSelectorState.BottomTextState.NetworkError, + viewModel.uiState.value.bottomTextState, + ) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) } @Test fun `Should show appropriate message when there's a network error checking for estimated imagery size`() = - runTest { + runWithTestDispatcher { setupMocks(estimatedSizeOnDisk = Result.failure(UnknownHostException("unknown"))) viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() - assertEquals(BottomTextState.NetworkError, viewModel.bottomTextState.value) - assertEquals(false, viewModel.downloadButtonEnabled.value) + assertEquals( + OfflineAreaSelectorState.BottomTextState.NetworkError, + viewModel.uiState.value.bottomTextState, + ) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) } @Test - fun `Should show area too large when zoom is too low`() = runTest { + fun `Should show area too large when zoom is too low`() = runWithTestDispatcher { setupMocks() val lowZoomPosition = CAMERA_POSITION.copy(zoomLevel = 5.0f) viewModel.onMapCameraMoved(lowZoomPosition) advanceUntilIdle() - assertEquals(BottomTextState.AreaTooLarge, viewModel.bottomTextState.value) - assertEquals(false, viewModel.downloadButtonEnabled.value) + assertEquals( + OfflineAreaSelectorState.BottomTextState.AreaTooLarge, + viewModel.uiState.value.bottomTextState, + ) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) } @Test - fun `Should reset state on map drag`() = runTest { + fun `Should show area too large when estimated size exceeds max`() = runWithTestDispatcher { + val largeSizeBytes = 1024 * 1024 * 51 + setupMocks(estimatedSizeOnDisk = Result.success(largeSizeBytes)) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + + assertEquals( + OfflineAreaSelectorState.BottomTextState.AreaTooLarge, + viewModel.uiState.value.bottomTextState, + ) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) + } + + @Test + fun `Should reset state on map drag`() = runWithTestDispatcher { setupMocks() viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() - assertEquals(true, viewModel.downloadButtonEnabled.value) + assertEquals(true, viewModel.uiState.value.isDownloadButtonEnabled()) viewModel.onMapDragged() - assertNull(viewModel.bottomTextState.value) - assertEquals(false, viewModel.downloadButtonEnabled.value) + assertNull(viewModel.uiState.value.bottomTextState) + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) + } + + @Test + fun `Should show download button enabled only for AreaSize state`() = runWithTestDispatcher { + // AreaTooLarge + setupMocks(estimatedSizeOnDisk = Result.success(1024 * 1024 * 51)) + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) + + // NoImageryAvailable + setupMocks(hasHiResImagery = Result.success(false)) + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) + + // NetworkError + setupMocks(hasHiResImagery = Result.failure(SocketTimeoutException("timeout"))) + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.isDownloadButtonEnabled()) + + // AreaSize (valid downloadable area) + setupMocks(estimatedSizeOnDisk = Result.success(1024 * 1024 * 5)) + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + assertEquals(true, viewModel.uiState.value.isDownloadButtonEnabled()) + } + + @Test + fun `onDownloadClick when network unavailable should emit NetworkUnavailable event`() = + runWithTestDispatcher { + setupMocks(isNetworkConnected = false) + + viewModel.uiEvent.test { + viewModel.onDownloadClick() + assertEquals(OfflineAreaSelectorEvent.NetworkUnavailable, awaitItem()) + } + } + + @Test + fun `onDownloadClick should emit DownloadError on download failure and return to home screen`() = + runWithTestDispatcher { + @Suppress("TooGenericExceptionThrown") + val errorFlow = flow> { throw RuntimeException("download failed") } + setupMocks(downloadProgressFlow = errorFlow) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + + viewModel.uiEvent.test { + viewModel.onDownloadClick() + advanceUntilIdle() + + assertEquals(OfflineAreaSelectorEvent.DownloadError, awaitItem()) + assertEquals(OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen, awaitItem()) + } + } + + @Test + fun `onDownloadClick should start download and update progress`() = runWithTestDispatcher { + val progressFlow = MutableSharedFlow>() + setupMocks(downloadProgressFlow = progressFlow) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + + viewModel.onDownloadClick() + advanceUntilIdle() + + progressFlow.emit(Pair(50, 100)) + advanceUntilIdle() + + val state = viewModel.uiState.value.downloadState + assert(state is OfflineAreaSelectorState.DownloadState.InProgress) + assertEquals(0.5f, (state as OfflineAreaSelectorState.DownloadState.InProgress).progress) + } + + @Test + fun `stopDownloading should cancel job and reset download state`() = runWithTestDispatcher { + val progressFlow = MutableSharedFlow>() + setupMocks(downloadProgressFlow = progressFlow) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + + viewModel.onDownloadClick() + advanceUntilIdle() + + progressFlow.emit(Pair(50, 100)) + advanceUntilIdle() + + viewModel.stopDownloading() + + assertNull(viewModel.downloadJob) + assertEquals(OfflineAreaSelectorState.DownloadState.Idle, viewModel.uiState.value.downloadState) + } + + @Test + fun `onDownloadClick should navigate home after successful download`() = runWithTestDispatcher { + val progressFlow = flow { + emit(50 to 100) + emit(100 to 100) + } + setupMocks(downloadProgressFlow = progressFlow) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + + viewModel.uiEvent.test { + viewModel.onDownloadClick() + advanceUntilIdle() + + assertEquals(OfflineAreaSelectorEvent.NavigateOfflineAreaBackToHomeScreen, awaitItem()) + } + } + + @Test + fun `onCancelClick should emit NavigateUp event`() = runWithTestDispatcher { + viewModel.uiEvent.test { + viewModel.onCancelClick() + assertEquals(OfflineAreaSelectorEvent.NavigateUp, awaitItem()) + } } private suspend fun setupMocks( hasHiResImagery: Result = Result.success(true), estimatedSizeOnDisk: Result = Result.success(1024 * 1024 * 5), + isNetworkConnected: Boolean = true, + downloadProgressFlow: Flow> = MutableSharedFlow(), ) { whenever(offlineAreaRepository.hasHiResImagery(any())).thenReturn(hasHiResImagery) whenever(offlineAreaRepository.estimateSizeOnDisk(any())).thenReturn(estimatedSizeOnDisk) + whenever(networkManager.isNetworkConnected()).thenReturn(isNetworkConnected) + whenever(offlineAreaRepository.downloadTiles(any())).thenReturn(downloadProgressFlow) } private companion object { From f7a3bbf65a8209c80d0a45694d6107db3c93d3ab Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 10 Apr 2026 15:25:01 +0200 Subject: [PATCH 6/8] fix copyright --- .../ui/offlineareas/selector/model/OfflineAreaSelectorState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt index 8e56ca325f..2f61758bf7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt +++ b/app/src/main/java/org/groundplatform/android/ui/offlineareas/selector/model/OfflineAreaSelectorState.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 0992c60b64a2b835dbd9408676e0ec3c140e9bb6 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 13 Apr 2026 11:57:21 +0200 Subject: [PATCH 7/8] add tests for fragment --- .../OfflineAreaSelectorFragmentTest.kt | 144 +++++++++++++++--- 1 file changed, 123 insertions(+), 21 deletions(-) diff --git a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt index 376374a025..132b7aa3be 100644 --- a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt @@ -15,41 +15,51 @@ */ package org.groundplatform.android.ui.offlineareas.selector -import androidx.activity.ComponentActivity import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isNotDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest -import javax.inject.Inject +import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.repository.OfflineAreaRepository import org.groundplatform.android.system.NetworkManager import org.groundplatform.android.testrules.FragmentScenarioRule import org.groundplatform.android.ui.offlineareas.selector.model.OfflineAreaSelectorState +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.map.Bounds import org.hamcrest.CoreMatchers.not import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.mock import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowToast @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -57,19 +67,24 @@ import org.robolectric.RobolectricTestRunner class OfflineAreaSelectorFragmentTest : BaseHiltTest() { lateinit var fragment: OfflineAreaSelectorFragment - @Inject lateinit var viewModel: OfflineAreaSelectorViewModel + lateinit var viewModel: OfflineAreaSelectorViewModel + lateinit var navController: NavController - private val offlineAreaRepository: OfflineAreaRepository = mock() + @BindValue @Mock lateinit var offlineAreaRepository: OfflineAreaRepository @BindValue @Mock lateinit var networkManager: NetworkManager - @get:Rule val composeTestRule = createAndroidComposeRule() + @get:Rule val composeTestRule = createComposeRule() @get:Rule val fragmentScenario = FragmentScenarioRule() @Before override fun setUp() { super.setUp() - fragmentScenario.launchFragmentInHiltContainer { + fragmentScenario.launchFragmentWithNavController( + destId = R.id.offline_area_selector_fragment, + navControllerCallback = { navController = it }, + ) { fragment = this as OfflineAreaSelectorFragment + viewModel = ViewModelProvider(fragment)[OfflineAreaSelectorViewModel::class.java] } } @@ -104,35 +119,122 @@ class OfflineAreaSelectorFragmentTest : BaseHiltTest() { onView(withId(R.id.bottom_text)).check(matches(withText(""))) } - // TODO: Complete below test - // Issue URL: https://github.com/google/ground-android/issues/3032 @Test fun `stopDownloading cancels active download and updates UI state`() = runWithTestDispatcher { + val progressFlow = MutableSharedFlow>() + setupMocks(downloadProgressFlow = progressFlow) composeTestRule.setContent { DownloadProgressDialog(0f, {}) } - val progressFlow = MutableSharedFlow>() - whenever(offlineAreaRepository.downloadTiles(any())).thenReturn(progressFlow) + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() - viewModel.onDownloadClick() + onView(withId(R.id.download_button)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + .perform(click()) advanceUntilIdle() + composeTestRule + .onNodeWithText(getString(R.string.offline_map_imagery_download_progress_dialog_message)) + .isDisplayed() + progressFlow.emit(Pair(50, 100)) advanceUntilIdle() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) - .performClick() + composeTestRule.onNodeWithText(getString(R.string.cancel)).performClick() progressFlow.emit(Pair(75, 100)) - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) - .isNotDisplayed() + composeTestRule.onNodeWithText(getString(R.string.cancel)).isNotDisplayed() val state = viewModel.uiState.value assert(state.downloadState is OfflineAreaSelectorState.DownloadState.Idle) assert(viewModel.downloadJob == null) } - // TODO: Write `test test failure case displays toast` - // Issue URL: https://github.com/google/ground-android/issues/3038 + @Test + fun `download failure displays error toast`() = runWithTestDispatcher { + setupMocks(downloadProgressFlow = flow { throw RuntimeException("download failed") }) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + onView(withId(R.id.download_button)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + .perform(click()) + advanceUntilIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(1) + assertEquals( + getString(R.string.offline_area_download_error), + ShadowToast.getTextOfLatestToast(), + ) + } + + @Test + fun `network unavailable displays error popup`() = runWithTestDispatcher { + setupMocks(isNetworkConnected = false) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + onView(withId(R.id.download_button)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + .perform(click()) + advanceUntilIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(1) + assertEquals( + getString(R.string.connect_to_download_message), + ShadowToast.getTextOfLatestToast(), + ) + } + + @Test + fun `successful download navigates back to home screen`() = runWithTestDispatcher { + setupMocks(downloadProgressFlow = flow { emit(Pair(100, 100)) }) + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + onView(withId(R.id.download_button)) + .check(matches(isDisplayed())) + .check(matches(isEnabled())) + .perform(click()) + advanceUntilIdle() + + assertThat(navController.currentDestination!!.id).isEqualTo(R.id.home_screen_fragment) + } + + @Test + fun `cancel button triggers navigate up`() = runWithTestDispatcher { + setupMocks() + + viewModel.onMapCameraMoved(CAMERA_POSITION) + advanceUntilIdle() + onView(withId(R.id.cancel_button)).perform(click()) + advanceUntilIdle() + + assertThat(navController.currentDestination?.id) + .isNotEqualTo(R.id.offline_area_selector_fragment) + } + + private suspend fun setupMocks( + hasHiResImagery: Result = Result.success(true), + estimatedSizeOnDisk: Result = Result.success(1024 * 1024 * 5), + isNetworkConnected: Boolean = true, + downloadProgressFlow: Flow> = MutableSharedFlow(), + ) { + whenever(offlineAreaRepository.hasHiResImagery(any())).thenReturn(hasHiResImagery) + whenever(offlineAreaRepository.estimateSizeOnDisk(any())).thenReturn(estimatedSizeOnDisk) + whenever(networkManager.isNetworkConnected()).thenReturn(isNetworkConnected) + whenever(offlineAreaRepository.downloadTiles(any())).thenReturn(downloadProgressFlow) + } + + private companion object { + val CAMERA_POSITION = + CameraPosition( + Coordinates(0.5, 0.5), + 10.0f, + Bounds(Coordinates(0.0, 0.0), Coordinates(1.0, 1.0)), + ) + } } From 9e70a493b37d908b47dedb1b58c6f6d6813d143f Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 13 Apr 2026 12:01:56 +0200 Subject: [PATCH 8/8] fix code style check --- .../ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt index 132b7aa3be..80124ebefa 100644 --- a/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/offlineareas/selector/OfflineAreaSelectorFragmentTest.kt @@ -35,7 +35,6 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flow @@ -153,6 +152,7 @@ class OfflineAreaSelectorFragmentTest : BaseHiltTest() { @Test fun `download failure displays error toast`() = runWithTestDispatcher { + @Suppress("TooGenericExceptionThrown") setupMocks(downloadProgressFlow = flow { throw RuntimeException("download failed") }) viewModel.onMapCameraMoved(CAMERA_POSITION)