From dc40467e98b5d5bed986db59ad2f26a6e8b77d6b Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 18 Aug 2025 18:09:53 -0400 Subject: [PATCH 01/23] Rename test files --- ...workProfileTest.kt => NetworkAccountProfileTest.kt} | 10 +++++----- ...{getProfileAuth.json => getAccountProfileAuth.json} | 0 ...ProfileNoAuth.json => getAccountProfileNoAuth.json} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename core/network/src/test/java/com/crisiscleanup/core/network/model/{NetworkProfileTest.kt => NetworkAccountProfileTest.kt} (95%) rename core/network/src/test/resources/{getProfileAuth.json => getAccountProfileAuth.json} (100%) rename core/network/src/test/resources/{getProfileNoAuth.json => getAccountProfileNoAuth.json} (100%) diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkAccountProfileTest.kt similarity index 95% rename from core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt rename to core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkAccountProfileTest.kt index 3d132419..d98de0e6 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkAccountProfileTest.kt @@ -5,10 +5,10 @@ import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull -class NetworkProfileTest { +class NetworkAccountProfileTest { @Test - fun profileNoAuthResult() { - val account = TestUtil.decodeResource("/getProfileAuth.json") + fun profileAuthResult() { + val account = TestUtil.decodeResource("/getAccountProfileAuth.json") assertEquals(setOf(291L), account.approvedIncidents) assertEquals(true, account.hasAcceptedTerms) @@ -44,8 +44,8 @@ class NetworkProfileTest { } @Test - fun profileAuthResult() { - val account = TestUtil.decodeResource("/getProfileNoAuth.json", true) + fun profileNoAuthResult() { + val account = TestUtil.decodeResource("/getAccountProfileNoAuth.json", true) assertNull(account.approvedIncidents) assertNull(account.hasAcceptedTerms) diff --git a/core/network/src/test/resources/getProfileAuth.json b/core/network/src/test/resources/getAccountProfileAuth.json similarity index 100% rename from core/network/src/test/resources/getProfileAuth.json rename to core/network/src/test/resources/getAccountProfileAuth.json diff --git a/core/network/src/test/resources/getProfileNoAuth.json b/core/network/src/test/resources/getAccountProfileNoAuth.json similarity index 100% rename from core/network/src/test/resources/getProfileNoAuth.json rename to core/network/src/test/resources/getAccountProfileNoAuth.json From 55efea9a3540c165c8fdbdbc571b6a7f0f8a5651 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 21 Aug 2025 16:34:15 -0400 Subject: [PATCH 02/23] Refactor org invite and transfer view state --- .../RequestOrgAccessViewModel.kt | 7 ++---- .../ui/RequestOrgAccessScreen.kt | 22 +++++++++++++------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index 33464b8d..cd0a3605 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -88,8 +88,6 @@ class RequestOrgAccessViewModel @Inject constructor( TransferOrgOption.All, TransferOrgOption.DoNotTransfer, ) - var selectedOrgTransfer by mutableStateOf(TransferOrgOption.NotSelected) - private set var transferOrgErrorMessage by mutableStateOf("") private set val isTransferringOrg = MutableStateFlow(false) @@ -317,12 +315,11 @@ class RequestOrgAccessViewModel @Inject constructor( } } - fun onChangeTransferOrgOption(option: TransferOrgOption) { - selectedOrgTransfer = option + fun onChangeTransferOrgOption() { transferOrgErrorMessage = "" } - fun onTransferOrg() { + fun onTransferOrg(selectedOrgTransfer: TransferOrgOption) { when (selectedOrgTransfer) { TransferOrgOption.DoNotTransfer -> isInviteRequested.value = true TransferOrgOption.Users, diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt index b02aadeb..70ed8969 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt @@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -243,6 +245,8 @@ private fun InviteExistingUserContent( val t = LocalAppTranslator.current val translationCount by t.translationCount.collectAsStateWithLifecycle() + var selectedOrgTransfer by remember { mutableStateOf(TransferOrgOption.NotSelected) } + val inviteInfo = displayInfo.inviteInfo val transferInstructions = t("invitationSignup.inviting_to_transfer_confirm") .replace("{user}", inviteInfo.displayName) @@ -254,13 +258,15 @@ private fun InviteExistingUserContent( listItemModifier, ) - val selectedOption = viewModel.selectedOrgTransfer for (option in viewModel.transferOrgOptions) { CrisisCleanupRadioButton( listItemModifier, - option == selectedOption, + option == selectedOrgTransfer, text = t(option.translateKey), - onSelect = { viewModel.onChangeTransferOrgOption(option) }, + onSelect = { + selectedOrgTransfer = option + viewModel.onChangeTransferOrgOption() + }, enabled = isEditable, ) } @@ -279,21 +285,21 @@ private fun InviteExistingUserContent( } BusyButton( fillWidthPadded.testTag("transferOrgSubmitAction"), - enabled = isEditable && selectedOption != TransferOrgOption.NotSelected, + enabled = isEditable && selectedOrgTransfer != TransferOrgOption.NotSelected, text = transferText, indicateBusy = isLoading, onClick = { - if (selectedOption == TransferOrgOption.DoNotTransfer) { + if (selectedOrgTransfer == TransferOrgOption.DoNotTransfer) { onBack() } else { - viewModel.onTransferOrg() + viewModel.onTransferOrg(selectedOrgTransfer) } }, ) } @Composable -private fun OrgTransferSuccessView( +private fun ColumnScope.OrgTransferSuccessView( orgName: String, onForgotPassword: () -> Unit, onLogin: () -> Unit, @@ -312,6 +318,8 @@ private fun OrgTransferSuccessView( listItemModifier, ) + Spacer(Modifier.weight(1f)) + CrisisCleanupOutlinedButton( modifier = listItemModifier .actionHeight(), From e936ef876eb788f5e0d49efe8dabf9c7927b3b74 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 25 Aug 2025 11:40:21 -0400 Subject: [PATCH 03/23] Keep visual state consistent while transfering orgs --- .../RequestOrgAccessViewModel.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index cd0a3605..869c663c 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -16,6 +16,7 @@ import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.common.subscribedReplay import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AccountUpdateRepository import com.crisiscleanup.core.data.repository.ChangeOrganizationAction @@ -34,15 +35,16 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.net.URL import javax.inject.Inject -import com.crisiscleanup.core.common.combine as combineMore @HiltViewModel class RequestOrgAccessViewModel @Inject constructor( @@ -117,35 +119,41 @@ class RequestOrgAccessViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) - val isLoading = combineMore( - isPullingLanguageOptions, + private val isStateTransient = combine( isFetchingInviteInfo, isRequestingInvite, isTransferringOrg, - ) { b0, b1, b2, b3 -> - b0 || b1 || b2 || b3 - } + ::Triple, + ) + .map { (b0, b1, b2) -> b0 || b1 || b2 } + .distinctUntilChanged() + .shareIn( + scope = viewModelScope, + started = subscribedReplay(1), + replay = 1, + ) + + val isLoading = combine( + isPullingLanguageOptions, + isStateTransient, + ) { b0, b1 -> b0 || b1 } + .distinctUntilChanged() .stateIn( scope = viewModelScope, initialValue = false, started = SharingStarted.WhileSubscribed(), ) - var emailAddress by mutableStateOf("") - var emailAddressError by mutableStateOf("") - - val isEditable = combine( - isFetchingInviteInfo, - isRequestingInvite, - ::Pair, - ) - .map { (b0, b1) -> !(b0 || b1) } + val isEditable = isStateTransient.map(Boolean::not) .stateIn( scope = viewModelScope, initialValue = false, started = SharingStarted.WhileSubscribed(), ) + var emailAddress by mutableStateOf("") + var emailAddressError by mutableStateOf("") + init { requestedOrg .onEach { result -> From 4cf9dbc55a196397606e30a66e9d4886869106ea Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 25 Aug 2025 13:51:47 -0400 Subject: [PATCH 04/23] Update transfer org state in correct sequence --- .../authentication/RequestOrgAccessViewModel.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index 869c663c..7c4e55f1 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -342,12 +342,6 @@ class RequestOrgAccessViewModel @Inject constructor( ChangeOrganizationAction.All } transferToOrg(action) - - val isAuthenticated = accountDataRepository.isAuthenticated.first() - if (isAuthenticated) { - clearInviteCode() - accountEventBus.onLogout() - } } finally { isTransferringOrg.value = false } @@ -360,8 +354,16 @@ class RequestOrgAccessViewModel @Inject constructor( } private suspend fun transferToOrg(action: ChangeOrganizationAction) { - if (accountUpdateRepository.acceptOrganizationChange(action, invitationCode)) { + val isAuthenticated = accountDataRepository.isAuthenticated.first() + + val isTransferred = accountUpdateRepository.acceptOrganizationChange(action, invitationCode) + if (isTransferred) { isOrgTransferred.value = true + + if (isAuthenticated) { + clearInviteCode() + accountEventBus.onLogout() + } } else { logger.logException(Exception("User transfer to org failed.")) transferOrgErrorMessage = From 7b1303cd0765811b6c014ea1919c37b06de4dfa7 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 25 Aug 2025 13:52:01 -0400 Subject: [PATCH 05/23] Show organization in account profile --- .../feature/authentication/ui/RootAuthScreen.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt index 20a6abaa..edb161ac 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt @@ -145,12 +145,19 @@ private fun AuthenticatedScreen( val t = LocalAppTranslator.current Text( - modifier = fillWidthPadded.testTag("authedAccountInfoText"), + modifier = fillWidthPadded.testTag("accountInfoText"), text = t("info.account_is") .replace("{full_name}", accountData.fullName) .replace("{email_address}", accountData.emailAddress), ) + if (accountData.org.name.isNotBlank()) { + Text( + modifier = fillWidthPadded.testTag("organizationText"), + text = t(accountData.org.name), + ) + } + val authErrorMessage by viewModel.errorMessage ConditionalErrorMessage(authErrorMessage, "authenticated") From fb22b03e6e5f52777f65e585f6b7ae23dfbfcb22 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 25 Aug 2025 16:16:32 -0400 Subject: [PATCH 06/23] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7acdabb0..a7e3c8b7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 268 + val buildVersion = 269 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" From a23781b517db305584aa7d62609ce16540b7b9b5 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 25 Aug 2025 16:40:53 -0400 Subject: [PATCH 07/23] Update translation --- .../feature/authentication/ui/ResetPasswordScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt index ce8f0e08..537d671a 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt @@ -74,7 +74,7 @@ fun ResetPasswordRoute( TopAppBarBackAction( title = t("actions.reset_password"), onAction = clearStateOnBack, - modifier = Modifier.testTag("passwordRecoverBackBtn"), + modifier = Modifier, ) if (isPasswordReset) { @@ -191,7 +191,7 @@ private fun ResetPasswordView( modifier = fillWidthPadded.testTag("resetPasswordBtn"), onClick = viewModel::onResetPassword, enabled = isEditable, - text = translator("actions.reset"), + text = translator("actions.reset_password"), indicateBusy = isBusy, ) } From e27dd8dbf94fb2728ccc1f56c636b8d2175474ac Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 25 Aug 2025 18:14:15 -0400 Subject: [PATCH 08/23] Retain state on successful org transfer for seamless transition --- app/build.gradle.kts | 2 +- .../RequestOrgAccessViewModel.kt | 35 +++++++++++- .../ui/RequestOrgAccessScreen.kt | 54 +++++++++++++------ 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7e3c8b7..e504b4f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 269 + val buildVersion = 270 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index 7c4e55f1..2fd58bab 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -45,6 +45,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.net.URL import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.ExperimentalTime @HiltViewModel class RequestOrgAccessViewModel @Inject constructor( @@ -60,11 +62,19 @@ class RequestOrgAccessViewModel @Inject constructor( @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Onboarding) private val logger: AppLogger, ) : ViewModel() { + companion object { + private var recentOrgTransfer = RecentOrgTransfer() + } + private val editorArgs = RequestOrgAccessArgs(savedStateHandle) private val invitationCode = editorArgs.inviteCode ?: "" val showEmailInput = editorArgs.showEmailInput ?: false + @OptIn(ExperimentalTime::class) + val isRecentlyTransferred = recentOrgTransfer.isValidTransferCode(invitationCode) + val recentOrgTransferredTo = recentOrgTransfer.orgName + private val isFetchingInviteInfo = MutableStateFlow(!showEmailInput && invitationCode.isNotBlank()) @@ -353,6 +363,7 @@ class RequestOrgAccessViewModel @Inject constructor( } } + @OptIn(ExperimentalTime::class) private suspend fun transferToOrg(action: ChangeOrganizationAction) { val isAuthenticated = accountDataRepository.isAuthenticated.first() @@ -361,7 +372,12 @@ class RequestOrgAccessViewModel @Inject constructor( isOrgTransferred.value = true if (isAuthenticated) { - clearInviteCode() + recentOrgTransfer = RecentOrgTransfer( + invitationCode, + orgName = inviteDisplay.value?.inviteInfo?.orgName ?: "", + transferEpochSeconds = Clock.System.now().epochSeconds, + ) + accountEventBus.onLogout() } } else { @@ -388,3 +404,20 @@ enum class TransferOrgOption(val translateKey: String) { All("invitationSignup.yes_transfer_me_and_cases"), DoNotTransfer("invitationSignup.no_transfer"), } + +/* + * Hack for edge case when authenticated user is transferred and logs out + * Navigation graph changes losing state for success screen + * Use for preserving data in this transition (between navigation graphs) + */ +private data class RecentOrgTransfer( + val code: String = "", + val orgName: String = "", + val transferEpochSeconds: Long = 0, +) { + @ExperimentalTime + fun isValidTransferCode(compare: String): Boolean { + return code == compare && + transferEpochSeconds + 60 > Clock.System.now().epochSeconds + } +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt index 70ed8969..7ddef711 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt @@ -99,7 +99,13 @@ fun RequestOrgAccessRoute( onAction = clearStateOnBack, ) - if (inviteInfoErrorMessage.isNotBlank()) { + if (viewModel.isRecentlyTransferred) { + OnOrgTransferredView( + viewModel.recentOrgTransferredTo, + openForgotPassword = openForgotPassword, + openAuth = openAuth, + ) + } else if (inviteInfoErrorMessage.isNotBlank()) { Text( inviteInfoErrorMessage, listItemModifier.testTag("requestAccessInviteInfoError"), @@ -115,25 +121,12 @@ fun RequestOrgAccessRoute( onAction = clearStateOnBack, ) } else if (isOrgTransferred) { - val clearStateOpenAuth = remember(viewModel, openAuth) { - { - viewModel.clearInviteCode() - openAuth() - } - } - val clearStateForgotPassword = remember(viewModel, openForgotPassword) { - { - viewModel.clearInviteCode() - openForgotPassword() - } - } - val displayInfo by viewModel.inviteDisplay.collectAsStateWithLifecycle() val orgName = displayInfo?.inviteInfo?.orgName ?: "" - OrgTransferSuccessView( + OnOrgTransferredView( orgName, - onForgotPassword = clearStateForgotPassword, - onLogin = clearStateOpenAuth, + openForgotPassword = openForgotPassword, + openAuth = openAuth, ) } else { RequestOrgUserInfoInputView( @@ -143,6 +136,33 @@ fun RequestOrgAccessRoute( } } +@Composable +private fun ColumnScope.OnOrgTransferredView( + orgName: String, + openForgotPassword: () -> Unit, + openAuth: () -> Unit, + viewModel: RequestOrgAccessViewModel = hiltViewModel(), +) { + val clearStateOpenAuth = remember(viewModel, openAuth) { + { + viewModel.clearInviteCode() + openAuth() + } + } + val clearStateForgotPassword = remember(viewModel, openForgotPassword) { + { + viewModel.clearInviteCode() + openForgotPassword() + } + } + + OrgTransferSuccessView( + orgName, + onForgotPassword = clearStateForgotPassword, + onLogin = clearStateOpenAuth, + ) +} + @Composable private fun RequestOrgUserInfoInputView( onBack: () -> Unit, From 36c41d3f236cd294e7fca80bf0deae95d3070ef7 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 26 Aug 2025 15:42:50 -0400 Subject: [PATCH 09/23] Apply consistent styles to top level views --- app/build.gradle.kts | 2 +- .../com/crisiscleanup/ui/CrisisCleanupApp.kt | 83 +++++++++++-------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e504b4f6..c092c9bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 270 + val buildVersion = 271 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 1d210ffa..6cd6cb2b 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -147,6 +147,12 @@ private fun BoxScope.LoadedContent( val orgUserInviteCode by viewModel.orgUserInvites.collectAsStateWithLifecycle("") val showOrgInviteTransfer = orgUserInviteCode.isNotBlank() + val contentModifier = Modifier + .background(navigationContainerColor) + .semantics { + testTagsAsResourceId = true + } + if (openAuthentication || isNotAuthenticatedState ) { @@ -158,6 +164,7 @@ private fun BoxScope.LoadedContent( appState, !isNotAuthenticatedState, toggleAuthentication, + modifier = contentModifier, ) if (isNotAuthenticatedState) { @@ -196,6 +203,7 @@ private fun BoxScope.LoadedContent( isLoading, viewModel.isAcceptingTerms, setAcceptingTerms, + contentModifier, onRejectTerms = viewModel::onRejectTerms, onAcceptTerms = viewModel::onAcceptTerms, errorMessage = viewModel.acceptTermsErrorMessage, @@ -220,6 +228,7 @@ private fun BoxScope.LoadedContent( isOnboarding = isOnboarding, menuTutorialStep, viewModel.tutorialViewTracker.viewSizePositionLookup, + contentModifier, viewModel::onMenuTutorialNext, ) { openAuthentication = true } @@ -261,33 +270,52 @@ private fun BoxScope.LoadedContent( } @Composable -private fun AuthenticateContent( +private fun ScaffoldBox( snackbarHostState: SnackbarHostState, - appState: CrisisCleanupAppState, - enableBackHandler: Boolean, - toggleAuthentication: (Boolean) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, ) { Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, + modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, contentWindowInsets = WindowInsets.systemBars, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - CrisisCleanupAuthNavHost( - navController = appState.navController, - enableBackHandler = enableBackHandler, - closeAuthentication = { toggleAuthentication(false) }, - onBack = appState::onBack, - modifier = Modifier + Box( + Modifier .fillMaxSize() .padding(padding) .consumeWindowInsets(padding) .windowInsetsPadding( WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), ), + ) { + content() + } + } +} + +@Composable +private fun AuthenticateContent( + snackbarHostState: SnackbarHostState, + appState: CrisisCleanupAppState, + enableBackHandler: Boolean, + toggleAuthentication: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ScaffoldBox( + snackbarHostState, + modifier, + ) { + CrisisCleanupAuthNavHost( + navController = appState.navController, + enableBackHandler = enableBackHandler, + closeAuthentication = { toggleAuthentication(false) }, + onBack = appState::onBack, + modifier = Modifier + .background(Color.White) + .fillMaxSize(), ) } } @@ -300,19 +328,15 @@ private fun AcceptTermsContent( isLoading: Boolean, isAcceptingTerms: Boolean, setAcceptingTerms: (Boolean) -> Unit, + modifier: Modifier = Modifier, onRejectTerms: () -> Unit = {}, onAcceptTerms: () -> Unit = {}, errorMessage: String = "", ) { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - contentWindowInsets = WindowInsets.systemBars, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> + ScaffoldBox( + snackbarHostState, + modifier, + ) { AcceptTermsView( termsOfServiceUrl, privacyPolicyUrl, @@ -320,12 +344,8 @@ private fun AcceptTermsContent( isAcceptingTerms = isAcceptingTerms, setAcceptingTerms = setAcceptingTerms, modifier = Modifier - .fillMaxSize() - .padding(padding) - .consumeWindowInsets(padding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), - ), + .background(Color.White) + .fillMaxSize(), onRejectTerms = onRejectTerms, onAcceptTerms = onAcceptTerms, errorMessage = errorMessage, @@ -341,6 +361,7 @@ private fun NavigableContent( isOnboarding: Boolean, menuTutorialStep: TutorialStep, tutorialViewLookup: SnapshotStateMap, + modifier: Modifier = Modifier, advanceMenuTutorial: () -> Unit, openAuthentication: () -> Unit, ) { @@ -353,11 +374,7 @@ private fun NavigableContent( } Scaffold( - modifier = Modifier - .background(navigationContainerColor) - .semantics { - testTagsAsResourceId = true - }, + modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, contentWindowInsets = WindowInsets(0, 0, 0, 0), From b0d0be44686176d36bfd828956810be790609c4b Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 26 Aug 2025 16:24:57 -0400 Subject: [PATCH 10/23] Show info form when user chooses to request access to org --- .../RequestOrgAccessViewModel.kt | 2 + .../ui/RequestOrgAccessScreen.kt | 43 +++++++++++++------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index 2fd58bab..79d1d4c4 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -71,6 +71,8 @@ class RequestOrgAccessViewModel @Inject constructor( private val invitationCode = editorArgs.inviteCode ?: "" val showEmailInput = editorArgs.showEmailInput ?: false + val isFromInvite = invitationCode.isNotBlank() + @OptIn(ExperimentalTime::class) val isRecentlyTransferred = recentOrgTransfer.isValidTransferCode(invitationCode) val recentOrgTransferredTo = recentOrgTransfer.orgName diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt index 7ddef711..70bd4163 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt @@ -2,12 +2,14 @@ package com.crisiscleanup.feature.authentication.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -35,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.AnimatedBusyIndicator import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton @@ -171,7 +174,7 @@ private fun RequestOrgUserInfoInputView( val displayInfo by viewModel.inviteDisplay.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() - if (displayInfo == null) { + if (viewModel.isFromInvite && displayInfo == null) { Box(Modifier.fillMaxSize()) { BusyIndicatorFloatingTopCenter(true) } @@ -206,7 +209,7 @@ private fun RequestOrgUserInfoInputView( scrollState, isEditable = isEditable, isLoading = isLoading, - displayInfo!!, + displayInfo, ) } } @@ -362,7 +365,7 @@ private fun InviteNewUserContent( scrollState: ScrollState, isEditable: Boolean, isLoading: Boolean, - displayInfo: InviteDisplayInfo, + displayInfo: InviteDisplayInfo?, viewModel: RequestOrgAccessViewModel = hiltViewModel(), ) { val t = LocalAppTranslator.current @@ -402,17 +405,29 @@ private fun InviteNewUserContent( onNext = clearErrorVisuals, ) } else { - val info = displayInfo - val avatarUrl = displayInfo.avatarUrl - if (avatarUrl != null && - info.displayName.isNotBlank() && - info.inviteMessage.isNotBlank() - ) { - InviterAvatar( - avatarUrl, - displayName = info.displayName, - inviteMessage = info.inviteMessage, - ) + if (displayInfo == null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + AnimatedBusyIndicator( + isLoading, + padding = 16.dp, + ) + } + } else { + val info = displayInfo + val avatarUrl = displayInfo.avatarUrl + if (avatarUrl != null && + info.displayName.isNotBlank() && + info.inviteMessage.isNotBlank() + ) { + InviterAvatar( + avatarUrl, + displayName = info.displayName, + inviteMessage = info.inviteMessage, + ) + } } } From 8eeacd6b2bd8a665820b0a613d7d0a01c8f2367c Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 26 Aug 2025 16:56:56 -0400 Subject: [PATCH 11/23] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c092c9bc..9ae68492 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 271 + val buildVersion = 272 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" From 39b92367475d1aae650e636adb14bf52d82d76cd Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 27 Aug 2025 11:47:33 -0400 Subject: [PATCH 12/23] Correct background color based on system theme --- .../java/com/crisiscleanup/MainActivity.kt | 28 ++--------------- .../com/crisiscleanup/ui/CrisisCleanupApp.kt | 2 -- .../core/designsystem/component/Background.kt | 8 ----- .../core/designsystem/theme/Theme.kt | 30 ++----------------- 4 files changed, 5 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 0af36a58..2f00f0a5 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -1,17 +1,14 @@ package com.crisiscleanup import android.content.Intent -import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,7 +21,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats import com.crisiscleanup.MainActivityViewState.Loading -import com.crisiscleanup.MainActivityViewState.Success import com.crisiscleanup.core.common.NetworkMonitor import com.crisiscleanup.core.common.PermissionManager import com.crisiscleanup.core.common.PhoneNumberPicker @@ -38,7 +34,6 @@ import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme -import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.sync.initializers.scheduleSyncWorksites import com.crisiscleanup.ui.CrisisCleanupApp import com.crisiscleanup.ui.rememberCrisisCleanupAppState @@ -118,7 +113,7 @@ class MainActivity : ComponentActivity() { } setContent { - val darkTheme = shouldUseDarkTheme(viewState) + val darkTheme = isSystemInDarkTheme() val windowSizeClass = calculateWindowSizeClass(this) val appState = rememberCrisisCleanupAppState( @@ -126,10 +121,7 @@ class MainActivity : ComponentActivity() { windowSizeClass = windowSizeClass, ) - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - ) + enableEdgeToEdge() CompositionLocalProvider { CrisisCleanupTheme( @@ -217,19 +209,3 @@ class MainActivity : ComponentActivity() { } } } - -/** - * Returns `true` if dark theme should be used, as a function of the [viewState] and the - * current system context. - */ -@Composable -private fun shouldUseDarkTheme( - viewState: MainActivityViewState, -): Boolean = when (viewState) { - Loading -> isSystemInDarkTheme() - is Success -> when (viewState.userData.darkThemeConfig) { - DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme() - DarkThemeConfig.LIGHT -> false - DarkThemeConfig.DARK -> true - } -} diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 6cd6cb2b..5421ae0b 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -59,7 +59,6 @@ import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.theme.LocalDimensions -import com.crisiscleanup.core.designsystem.theme.navigationContainerColor import com.crisiscleanup.core.model.data.TutorialViewId import com.crisiscleanup.core.ui.AppLayoutArea import com.crisiscleanup.core.ui.LayoutSizePosition @@ -148,7 +147,6 @@ private fun BoxScope.LoadedContent( val showOrgInviteTransfer = orgUserInviteCode.isNotBlank() val contentModifier = Modifier - .background(navigationContainerColor) .semantics { testTagsAsResourceId = true } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt index 77b1266c..01e2cca7 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt @@ -51,14 +51,6 @@ annotation class ThemePreviews @ThemePreviews @Composable fun BackgroundDefault() { - CrisisCleanupTheme(disableDynamicTheming = true) { - CrisisCleanupBackground(Modifier.size(100.dp), content = {}) - } -} - -@ThemePreviews -@Composable -fun BackgroundDynamic() { CrisisCleanupTheme { CrisisCleanupBackground(Modifier.size(100.dp), content = {}) } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt index b3fdac43..c2cdae8e 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt @@ -1,7 +1,5 @@ package com.crisiscleanup.core.designsystem.theme -import android.os.Build -import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -116,32 +114,13 @@ val DarkColors = darkColorScheme( fun CrisisCleanupTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, -) = CrisisCleanupTheme( - darkTheme = darkTheme, - disableDynamicTheming = false, - content = content, -) - -/** - * App theme. This is an internal only version, to allow disabling dynamic theming - * in tests. - * - * @param darkTheme Whether the theme should use a dark color scheme (follows system by default). - * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is - * supported. - */ -@Composable -internal fun CrisisCleanupTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - disableDynamicTheming: Boolean, - content: @Composable () -> Unit, ) { // Color scheme - val colorScheme = SingleColors + val colorScheme = if (darkTheme) DarkColors else LightColors // Background theme val defaultBackgroundTheme = BackgroundTheme( - color = colorScheme.background, + color = colorScheme.surface, ) val configuration = LocalConfiguration.current @@ -159,12 +138,9 @@ internal fun CrisisCleanupTheme( LocalFontStyles provides CrisisCleanupTypographyDefault, ) { MaterialTheme( - colorScheme = colorScheme, + colorScheme = SingleColors, typography = AppTypography, content = content, ) } } - -@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) -private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S From e99b70ac0af8d81b46525adf866b7003d046b0a3 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 27 Aug 2025 11:59:08 -0400 Subject: [PATCH 13/23] Offset download update icon --- app/build.gradle.kts | 2 +- .../main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ae68492..d105a693 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 272 + val buildVersion = 273 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index fdfd1777..f638c950 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -752,7 +752,7 @@ private fun AppUpdateView() { t("~~A new version of the app is available"), Modifier.onGloballyPositioned { badgeOffsetX = with(localDensity) { - -it.size.width.div(2).toDp() + -it.size.width.div(2).toDp().plus(4.dp) } }, ) From 89e45209cd26c823192f94552fdffa7f13db0ffc Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 2 Sep 2025 13:03:19 -0400 Subject: [PATCH 14/23] Sync Incident changed or invalidated Worksites from backend --- .../com/crisiscleanup/CrisisCleanupAppEnv.kt | 2 +- .../CrisisCleanupInterceptorProvider.kt | 12 +- .../repository/IncidentCacheRepository.kt | 47 +++++- .../OfflineFirstWorksitesRepository.kt | 20 +++ .../data/repository/WorksitesRepository.kt | 7 + .../database/TestCrisisCleanupDatabase.kt | 13 ++ .../dao/WorksiteSyncReconciliationTest.kt | 134 ++++++++++++++++++ .../core/database/dao/RecentWorksiteDao.kt | 2 +- .../core/database/dao/WorksiteDao.kt | 23 +++ .../core/database/dao/WorksiteDaoPlus.kt | 44 ++++++ .../database/model/IncidentWorksiteIds.kt | 14 ++ .../crisiscleanup/core/data/app_metrics.proto | 3 + .../data/indicent_cache_preferences.proto | 1 + .../core/data/user_preferences.proto | 7 +- .../IncidentCachePreferencesDataSource.kt | 15 +- .../LocalAppPreferencesDataSource.kt | 7 - .../LocalAppPreferencesDataSourceTest.kt | 95 ------------- .../data/IncidentWorksitesCachePreferences.kt | 4 + .../crisiscleanup/core/model/data/UserData.kt | 2 - .../network/CrisisCleanupNetworkDataSource.kt | 3 + .../network/model/NetworkWorksiteChange.kt | 21 +++ .../core/network/retrofit/DataApiClient.kt | 12 ++ .../core/testing/model/UserData.kt | 2 - .../caseeditor/CaseEditorDataLoader.kt | 1 + .../ui/IncidentWorksitesCacheScreen.kt | 1 + 25 files changed, 368 insertions(+), 124 deletions(-) create mode 100644 core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt create mode 100644 core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt diff --git a/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt b/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt index 2f6eef5c..ac817dd7 100644 --- a/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt +++ b/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt @@ -20,7 +20,7 @@ class CrisisCleanupAppEnv @Inject constructor( val apiUrl = settingsProvider.apiBaseUrl return when { apiUrl.startsWith("https://api.dev.crisiscleanup.io") -> "Dev" - apiUrl.startsWith("https://api.staging.crisiscleanup.io") -> "Staging" + apiUrl.startsWith("https://crisiscleanup-3-api-staging.up.railway.app") -> "Staging" apiUrl.startsWith("https://api.crisiscleanup.org") -> "Production" else -> "Local?" } diff --git a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt index ecaaadbc..29a61b10 100644 --- a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt +++ b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt @@ -123,20 +123,17 @@ class CrisisCleanupInterceptorProvider @Inject constructor( .build() } - private fun isExpiredToken(response: Response, logPaths: String): Pair { + private fun isExpiredToken(response: Response): Pair { if (response.code == 401) { return Pair(true, response) } - response.body?.let { responseBody -> + response.body.let { responseBody -> val body = responseBody.string() val errors = json.parseNetworkErrors(body) val bodyCopy = body.toResponseBody(responseBody.contentType()) val copyResponse = response.newBuilder().body(bodyCopy).build() return Pair(errors.hasExpiredToken, copyResponse) } - // TODO If body is null from above wouldn't response need to close (and rebuild)? - logger.logCapture("Token was not expired and body was null for $logPaths. Incoming exception?") - return Pair(false, response) } private val invalidRefreshTokenErrorMessages = setOf( @@ -169,10 +166,7 @@ class CrisisCleanupInterceptorProvider @Inject constructor( private fun tryAuthRequest(chain: Interceptor.Chain, request: Request): Response { val response = chain.proceed(request) - val (isExpired, nextResponse) = isExpiredToken( - response, - request.pathsForLog, - ) + val (isExpired, nextResponse) = isExpiredToken(response) if (isExpired) { logger.logCapture("Expired token trying refresh ${request.pathsForLog}") runBlocking { diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index 0ec74e1b..b2bd0cad 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -12,6 +12,7 @@ import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.radians +import com.crisiscleanup.core.common.split import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.common.sync.SyncResult import com.crisiscleanup.core.data.IncidentMapTracker @@ -42,6 +43,7 @@ import com.crisiscleanup.core.model.data.IncidentWorksitesCachePreferences import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.KeyDynamicValuePair import com.crisiscleanup.core.network.model.NetworkFlagsFormData +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteFull import com.crisiscleanup.core.network.model.NetworkWorksitePage import com.crisiscleanup.core.network.model.WorksiteDataResult @@ -54,13 +56,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.combine as kCombine @@ -489,6 +491,17 @@ class IncidentWorksitesCacheRepository @Inject constructor( ensureActive() worksitesAdditionalStatsUpdater.clearStep() } + + if (!(isPaused || isSlowDownload)) { + ensureActive() + + logStage(incidentId, IncidentCacheStage.WorksitesChangedIncident) + + updateChangedIncidentWorksites( + syncPlan.restartCache, + syncPreferences.lastReconciled, + ) + } } catch (e: Exception) { with(incidentDataPullStats.value) { if (queryCount < dataCount) { @@ -1278,7 +1291,36 @@ class IncidentWorksitesCacheRepository @Inject constructor( } override suspend fun updateCachePreferences(preferences: IncidentWorksitesCachePreferences) { - incidentCachePreferences.setPreferences(preferences) + incidentCachePreferences.setPauseRegionPreferences(preferences) + } + + private suspend fun updateChangedIncidentWorksites( + restartCache: Boolean, + lastReconciled: Instant, + ) { + val minTimestamp = Clock.System.now().minus(45.days) + val queryAfter = + if (restartCache) minTimestamp else lastReconciled.coerceAtLeast(minTimestamp) + try { + val reconcileStart = Clock.System.now() + + val worksiteChanges = networkDataSource.getWorksiteChanges(queryAfter) + + val (valid, invalid) = worksiteChanges.split { it.invalidatedAt == null } + val invalidWorksiteIds = invalid.map(NetworkWorksiteChange::worksiteId) + + val changedIncidents = worksitesRepository.processReconciliation( + valid.toList(), + invalidWorksiteIds, + ) + if (changedIncidents.isNotEmpty()) { + syncLogger.log("${changedIncidents.size} Cases changed Incidents.") + } + + incidentCachePreferences.setLastReconciled(reconcileStart) + } catch (e: Exception) { + appLogger.logException(e) + } } private data class DownloadCountSpeed( @@ -1297,6 +1339,7 @@ enum class IncidentCacheStage { WorksitesAdditional, ActiveIncident, ActiveIncidentOrganization, + WorksitesChangedIncident, End, } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index 917b9e20..2e023038 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt @@ -12,11 +12,13 @@ import com.crisiscleanup.core.database.dao.RecentWorksiteDao import com.crisiscleanup.core.database.dao.WorkTypeTransferRequestDaoPlus import com.crisiscleanup.core.database.dao.WorksiteDao import com.crisiscleanup.core.database.dao.WorksiteDaoPlus +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.PopulatedRecentWorksite import com.crisiscleanup.core.database.model.RecentWorksiteEntity import com.crisiscleanup.core.database.model.asExternalModel import com.crisiscleanup.core.database.model.asSummary import com.crisiscleanup.core.model.data.CasesFilter +import com.crisiscleanup.core.model.data.EmptyWorksite import com.crisiscleanup.core.model.data.IncidentIdWorksiteCount import com.crisiscleanup.core.model.data.OrganizationLocationAreaBounds import com.crisiscleanup.core.model.data.TableDataWorksite @@ -24,6 +26,7 @@ import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.getClaimStatus import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.CrisisCleanupWriteApi +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteFull import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview @@ -334,4 +337,21 @@ class OfflineFirstWorksitesRepository @Inject constructor( tableData } + + override suspend fun processReconciliation( + validChanges: List, + invalidatedNetworkWorksiteIds: List, + ): List { + val validIds = validChanges.map { + IncidentWorksiteIds( + incidentId = it.incidentId, + worksiteId = EmptyWorksite.id, + networkWorksiteId = it.worksiteId, + ) + } + val changedIncidents = worksiteDaoPlus.syncNetworkChangedIncidents(validIds) + worksiteDaoPlus.syncDeletedWorksites(invalidatedNetworkWorksiteIds) + + return changedIncidents + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt index a62316e5..75b3afce 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.core.data.repository +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.model.data.CasesFilter import com.crisiscleanup.core.model.data.IncidentIdWorksiteCount import com.crisiscleanup.core.model.data.LocalWorksite @@ -8,6 +9,7 @@ import com.crisiscleanup.core.model.data.Worksite import com.crisiscleanup.core.model.data.WorksiteMapMark import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.WorksiteSummary +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteFull import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Clock @@ -85,4 +87,9 @@ interface WorksitesRepository { searchRadius: Float = 100f, count: Int = 360, ): List + + suspend fun processReconciliation( + validChanges: List, + invalidatedNetworkWorksiteIds: List, + ): List } diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt index 50bc6a6f..7d3644c3 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt @@ -17,6 +17,7 @@ import com.crisiscleanup.core.database.model.IncidentIncidentLocationCrossRef import com.crisiscleanup.core.database.model.IncidentLocationEntity import com.crisiscleanup.core.database.model.IncidentOrganizationEntity import com.crisiscleanup.core.database.model.IncidentOrganizationSyncStatsEntity +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity import com.crisiscleanup.core.database.model.LanguageTranslationEntity @@ -163,6 +164,18 @@ interface TestWorksiteDao { limit: Int, offset: Int = 0, ): List + + @Transaction + @Query("SELECT * FROM worksites ORDER BY network_id") + fun getWorksites(): List + + @Transaction + @Query("SELECT id, incident_id, network_id FROM worksites_root ORDER BY id") + fun getRootWorksiteEntities(): List + + @Transaction + @Query("SELECT id, incident_id, network_id FROM worksites ORDER BY id") + fun getWorksiteEntities(): List } @Dao diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt new file mode 100644 index 00000000..bf0442b0 --- /dev/null +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt @@ -0,0 +1,134 @@ +package com.crisiscleanup.core.database.dao + +import com.crisiscleanup.core.database.TestCrisisCleanupDatabase +import com.crisiscleanup.core.database.TestUtil +import com.crisiscleanup.core.database.TestUtil.testAppLogger +import com.crisiscleanup.core.database.TestUtil.testSyncLogger +import com.crisiscleanup.core.database.TestWorksiteDao +import com.crisiscleanup.core.database.WorksiteTestUtil +import com.crisiscleanup.core.database.WorksiteTestUtil.testIncidents +import com.crisiscleanup.core.database.model.IncidentWorksiteIds +import com.crisiscleanup.core.database.model.WorksiteEntity +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +class WorksiteSyncReconciliationTest { + private lateinit var db: TestCrisisCleanupDatabase + + private lateinit var worksiteDao: TestWorksiteDao + private lateinit var worksiteDaoPlus: WorksiteDaoPlus + + private val syncLogger = testSyncLogger() + private val appLogger = testAppLogger() + + @Before + fun createDb() { + db = TestUtil.getTestDatabase() + worksiteDao = db.testWorksiteDao() + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) + } + + @Before + fun seedDb() = runTest { + val incidentDao = db.incidentDao() + incidentDao.upsertIncidents(testIncidents) + + val worksiteCreatedAt = Clock.System.now().minus(10.days) + val insertAt = Clock.System.now().minus(1.days) + insertWorksites( + listOf( + testWorksiteFullEntity( + 534, + 23, + worksiteCreatedAt.plus(1.hours), + ), + testWorksiteFullEntity( + 48, + 1, + worksiteCreatedAt.plus(2.hours), + ), + testWorksiteFullEntity( + 1654, + 456, + worksiteCreatedAt.plus(3.hours), + ), + testWorksiteFullEntity( + 9, + 23, + worksiteCreatedAt.plus(4.hours), + ), + testWorksiteFullEntity( + 987, + 23, + worksiteCreatedAt.plus(5.hours), + ), + ), + insertAt, + ) + } + + private suspend fun insertWorksites( + worksites: List, + syncedAt: Instant, + ) = WorksiteTestUtil.insertWorksites( + db, + syncedAt, + *worksites.toTypedArray(), + ) + + @Test + fun syncNetworkChangedIncidents() = runTest { + fun makeIncidentWorksiteIds(incidentId: Long, networkWorksiteId: Long) = + IncidentWorksiteIds( + incidentId = incidentId, + worksiteId = 0, + networkWorksiteId = networkWorksiteId, + ) + + val changes = worksiteDaoPlus.syncNetworkChangedIncidents( + listOf( + makeIncidentWorksiteIds(1, 534), + makeIncidentWorksiteIds(1, 987), + makeIncidentWorksiteIds(23, 1654), + ), + stepInterval = 2, + ) + + val expectedChanges = listOf( + IncidentWorksiteIds(1, 1, 534), + IncidentWorksiteIds(1, 5, 987), + IncidentWorksiteIds(23, 3, 1654), + ) + assertEquals(expectedChanges, changes) + + val orderedChanges = listOf( + expectedChanges[0], + IncidentWorksiteIds(1, 2, 48), + expectedChanges[2], + IncidentWorksiteIds(23, 4, 9), + expectedChanges[1], + ) + val worksiteIdsA = worksiteDao.getWorksiteEntities() + assertEquals(orderedChanges, worksiteIdsA) + val worksiteIdsB = worksiteDao.getRootWorksiteEntities() + assertEquals(orderedChanges, worksiteIdsB) + } + + @Test + fun syncDeletedWorksites() = runTest { + worksiteDaoPlus.syncDeletedWorksites( + listOf(987, 1654, 48), + stepInterval = 2, + ) + + val worksites = worksiteDao.getWorksites() + val networkWorksiteIds = worksites.map { it.entity.networkId } + assertEquals(listOf(9L, 534), networkWorksiteIds) + } +} diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt index 4967663e..d1c6eaf8 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt @@ -25,7 +25,7 @@ interface RecentWorksiteDao { ) fun streamRecentWorksites( incidentId: Long, - limit: Int = 16, + limit: Int = 30, offset: Int = 0, ): Flow> diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt index 2899131f..f9cc23ac 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt @@ -7,6 +7,7 @@ import androidx.room.Transaction import androidx.room.Update import com.crisiscleanup.core.database.dao.fts.PopulatedWorksiteTextMatchInfo import com.crisiscleanup.core.database.model.BoundedSyncedWorksiteIds +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.PopulatedFilterDataWorksite import com.crisiscleanup.core.database.model.PopulatedLocalModifiedAt import com.crisiscleanup.core.database.model.PopulatedLocalWorksite @@ -618,6 +619,28 @@ interface WorksiteDao { offset: Int, ): List + @Transaction + @Query( + """ + SELECT id, incident_id, network_id + FROM worksites_root + WHERE network_id IN(:networkWorksiteIds) + """, + ) + fun getWorksiteIds(networkWorksiteIds: List): List + + @Transaction + @Query("UPDATE worksites_root SET incident_id=:incidentId WHERE id=:id") + fun syncUpdateWorksiteRootIncident(id: Long, incidentId: Long) + + @Transaction + @Query("UPDATE worksites SET incident_id=:incidentId WHERE id=:id") + fun syncUpdateWorksiteIncident(id: Long, incidentId: Long) + + @Transaction + @Query("DELETE from worksites_root WHERE network_id IN(:networkWorksiteIds)") + fun deleteNetworkWorksites(networkWorksiteIds: Collection) + @Transaction @Query("SELECT case_number FROM worksites ORDER BY RANDOM() LIMIT 1") fun getRandomWorksiteCaseNumber(): String? diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt index a70d2518..4e67f2e2 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt @@ -10,6 +10,7 @@ import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.database.CrisisCleanupDatabase import com.crisiscleanup.core.database.model.BoundedSyncedWorksiteIds import com.crisiscleanup.core.database.model.CoordinateGridQuery +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.NetworkFileEntity import com.crisiscleanup.core.database.model.PopulatedFilterDataWorksite import com.crisiscleanup.core.database.model.PopulatedLocalModifiedAt @@ -837,4 +838,47 @@ class WorksiteDaoPlus @Inject constructor( IncidentIdWorksiteCount(incidentId, totalCount, count) } + + suspend fun syncNetworkChangedIncidents( + changeCandidates: List, + stepInterval: Int = 100, + ) = db.withTransaction { + val changedIncidentWorksites = mutableListOf() + + val worksiteDao = db.worksiteDao() + val iStep = stepInterval.coerceAtLeast(1) + for (i in changeCandidates.indices step iStep) { + val iEnd = (i + iStep).coerceAtMost(changeCandidates.size) + val chunk = changeCandidates.subList(i, iEnd) + val queryIds = chunk.map(IncidentWorksiteIds::networkWorksiteId) + val localLookup = worksiteDao.getWorksiteIds(queryIds) + .associateBy(IncidentWorksiteIds::networkWorksiteId) + val changed = chunk.mapNotNull { candidate -> + localLookup[candidate.networkWorksiteId]?.let { localMatch -> + return@mapNotNull candidate.copy(worksiteId = localMatch.worksiteId) + } + null + } + changedIncidentWorksites.addAll(changed) + } + + for (changed in changedIncidentWorksites) { + val id = changed.worksiteId + val incidentId = changed.incidentId + worksiteDao.syncUpdateWorksiteRootIncident(id, incidentId) + worksiteDao.syncUpdateWorksiteIncident(id, incidentId) + } + + changedIncidentWorksites + } + + suspend fun syncDeletedWorksites(networkIds: List, stepInterval: Int = 100) = + db.withTransaction { + val iStep = stepInterval.coerceAtLeast(1) + for (i in networkIds.indices step iStep) { + val iEnd = (i + iStep).coerceAtMost(networkIds.size) + val deleteIds = networkIds.subList(i, iEnd) + db.worksiteDao().deleteNetworkWorksites(deleteIds) + } + } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt new file mode 100644 index 00000000..8f475679 --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt @@ -0,0 +1,14 @@ +package com.crisiscleanup.core.database.model + +import androidx.room.ColumnInfo + +// Used as db model and external model +// Names must remain consistent +data class IncidentWorksiteIds( + @ColumnInfo("incident_id") + val incidentId: Long, + @ColumnInfo("id") + val worksiteId: Long, + @ColumnInfo("network_id") + val networkWorksiteId: Long, +) diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto index 13b690c9..e64ebc86 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto @@ -7,6 +7,9 @@ option java_package = "com.crisiscleanup.core.datastore"; option java_multiple_files = true; message AppMetrics { + + // ** Other files use snake case not camel case ** + AppEndUseProto earlybirdBuildEnd = 1; int64 appOpenSeconds = 2; diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto index 054e1d64..b8a40e4c 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto @@ -10,4 +10,5 @@ message IncidentCachePreferences { double region_longitude = 4; double region_radius_miles = 5; bool is_region_my_location = 6; + int64 case_reconciliation_seconds = 7; } diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index 6579db76..e0580b4e 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -18,14 +18,13 @@ message UserPreferences { DarkThemeConfigProto dark_theme_config = 2; - // General sync stats. Use for backoff in case of bad connection, errors, or other failures. - SyncAttemptProto sync_attempt = 3; + SyncAttemptProto sync_attempt = 3 [deprecated = true]; int64 selected_incident_id = 4; // Deprecated since OAuth and other auth options was added - int32 save_credentials_prompt_count = 5; - bool disable_save_credentials_prompt = 6; + int32 save_credentials_prompt_count = 5 [deprecated = true]; + bool disable_save_credentials_prompt = 6 [deprecated = true]; string language_key = 7; diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt index 22319e66..17bbc974 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import com.crisiscleanup.core.model.data.BoundedRegionParameters import com.crisiscleanup.core.model.data.IncidentWorksitesCachePreferences import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant import javax.inject.Inject class IncidentCachePreferencesDataSource @Inject constructor( @@ -19,10 +20,14 @@ class IncidentCachePreferencesDataSource @Inject constructor( regionLongitude = it.regionLongitude, regionRadiusMiles = it.regionRadiusMiles, ), + lastReconciled = Instant.fromEpochSeconds(it.caseReconciliationSeconds), ) } - suspend fun setPreferences(preferences: IncidentWorksitesCachePreferences) { + /** + * Updates preferences relating to pausing sync and region syncing + */ + suspend fun setPauseRegionPreferences(preferences: IncidentWorksitesCachePreferences) { dataStore.updateData { val regionParameters = preferences.boundedRegionParameters it.copy { @@ -35,4 +40,12 @@ class IncidentCachePreferencesDataSource @Inject constructor( } } } + + suspend fun setLastReconciled(lastReconciled: Instant) { + dataStore.updateData { + it.copy { + caseReconciliationSeconds = lastReconciled.epochSeconds + } + } + } } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index 4f49a6f3..715a5bd7 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -4,7 +4,6 @@ import androidx.datastore.core.DataStore import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.IncidentCoordinateBounds -import com.crisiscleanup.core.model.data.SyncAttempt import com.crisiscleanup.core.model.data.UserData import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.worksiteSortByFromLiteral @@ -36,12 +35,6 @@ class LocalAppPreferencesDataSource @Inject constructor( }, shouldHideOnboarding = it.shouldHideOnboarding, - syncAttempt = SyncAttempt( - it.syncAttempt.successfulSeconds, - it.syncAttempt.attemptedSeconds, - it.syncAttempt.attemptedCounter, - ), - selectedIncidentId = if (it.selectedIncidentId <= 0L) EmptyIncident.id else it.selectedIncidentId, languageKey = it.languageKey, diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt index 80c71e51..5ff313d3 100644 --- a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt @@ -1,21 +1,12 @@ package com.crisiscleanup.core.datastore import com.crisiscleanup.core.datastore.test.testUserPreferencesDataStore -import com.crisiscleanup.core.model.data.SyncAttempt -import com.crisiscleanup.core.model.data.UserData import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -42,90 +33,4 @@ class LocalAppPreferencesDataSourceTest { subject.setShouldHideOnboarding(true) assertTrue(subject.userData.first().shouldHideOnboarding) } - - @Test - fun syncAttemptDefault() = runTest { - val syncAttempt = SyncAttempt(0, 0, 0) - assertEquals(syncAttempt, subject.userData.first().syncAttempt) - } - - private fun setupSyncAttempt( - testBody: suspend TestScope.() -> Unit, - onAttempts: TestScope.(List) -> Unit, - ) = runTest { - val values = mutableListOf() - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - subject.userData.toList(values) - } - - try { - testBody() - - advanceUntilIdle() - yield() - - val attempts = values.map(UserData::syncAttempt) - onAttempts(attempts) - } finally { - collectJob.cancel() - } - } - - @Test - fun syncAttemptSuccessful() = runTest { - setupSyncAttempt( - { - subject.setSyncAttempt(true, 1582) - subject.setSyncAttempt(true, 19815) - subject.setSyncAttempt(false, 20158) - }, - ) { attempts: List -> - val expecteds = listOf( - SyncAttempt(0, 0, 0), - SyncAttempt(1582, 1582, 0), - SyncAttempt(19815, 19815, 0), - SyncAttempt(19815, 20158, 1), - ) - for (i in expecteds.indices) { - assertEquals(expecteds[i], attempts[i]) - } - } - } - - @Test - fun syncAttemptFail() = runTest { - setupSyncAttempt( - { - subject.setSyncAttempt(false, 1582) - subject.setSyncAttempt(false, 19815) - subject.setSyncAttempt(true, 20158) - }, - ) { attempts: List -> - val expecteds = listOf( - SyncAttempt(0, 0, 0), - SyncAttempt(0, 1582, 1), - SyncAttempt(0, 19815, 2), - SyncAttempt(20158, 20158, 0), - ) - for (i in expecteds.indices) { - // Without print statements this will fail at times due to no attempts... - val attempt = attempts[i] - val expected = expecteds[i] - assertEquals(expected, attempt) - } - } - } - - @Test - fun clearSyncData() = runTest { - subject.setSyncAttempt(true, 20158) - subject.setSyncAttempt(false, 58354) - - val syncAttempt = subject.userData.first().syncAttempt - assertEquals(SyncAttempt(20158, 58354, 1), syncAttempt) - - subject.clearSyncData() - val clearedSyncAttempt = subject.userData.first().syncAttempt - assertEquals(SyncAttempt(0, 0, 0), clearedSyncAttempt) - } } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt index 0f11f94d..92d47b63 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt @@ -1,5 +1,7 @@ package com.crisiscleanup.core.model.data +import kotlinx.datetime.Instant + const val BOUNDED_REGION_RADIUS_MILES_DEFAULT = 30.0 data class BoundedRegionParameters( @@ -15,6 +17,7 @@ data class IncidentWorksitesCachePreferences( val isPaused: Boolean, val isRegionBounded: Boolean, val boundedRegionParameters: BoundedRegionParameters, + val lastReconciled: Instant, ) { val isAutoCache by lazy { !(isPaused || isRegionBounded) @@ -33,4 +36,5 @@ val InitialIncidentWorksitesCachePreferences = IncidentWorksitesCachePreferences isPaused = false, isRegionBounded = false, boundedRegionParameters = BoundedRegionParametersNone, + lastReconciled = Instant.fromEpochSeconds(0), ) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt index 68ca232d..37e82643 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt @@ -8,8 +8,6 @@ data class UserData( val darkThemeConfig: DarkThemeConfig, val shouldHideOnboarding: Boolean, - val syncAttempt: SyncAttempt, - val selectedIncidentId: Long, val languageKey: String, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index 446aec72..07660c02 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -20,6 +20,7 @@ import com.crisiscleanup.core.network.model.NetworkTeamResult import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkWorkTypeRequest import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteCoreData import com.crisiscleanup.core.network.model.NetworkWorksiteFull import com.crisiscleanup.core.network.model.NetworkWorksiteLocationSearch @@ -213,4 +214,6 @@ interface CrisisCleanupNetworkDataSource { limit: Int = 0, offset: Int = 0, ): NetworkTeamResult + + suspend fun getWorksiteChanges(after: Instant): List } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt new file mode 100644 index 00000000..2fc780b7 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt @@ -0,0 +1,21 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkWorksiteChangesResult( + val error: String? = null, + val changes: List? = null, +) + +@Serializable +data class NetworkWorksiteChange( + @SerialName("incident_id") + val incidentId: Long, + @SerialName("worksite_id") + val worksiteId: Long, + @SerialName("invalidated_at") + val invalidatedAt: Instant?, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 5de64191..877a5845 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -22,6 +22,7 @@ import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkUsersResult import com.crisiscleanup.core.network.model.NetworkWorkTypeRequestResult import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult +import com.crisiscleanup.core.network.model.NetworkWorksiteChangesResult import com.crisiscleanup.core.network.model.NetworkWorksiteLocationSearchResult import com.crisiscleanup.core.network.model.NetworkWorksitesCoreDataResult import com.crisiscleanup.core.network.model.NetworkWorksitesFullResult @@ -366,6 +367,14 @@ private interface DataSourceApi { @Query("offset") offset: Int, ): NetworkTeamResult + + @TokenAuthenticationHeader + @WrapResponseHeader("changes") + @GET("worksites_changes") + suspend fun getWorksiteChanges( + @Query("since") + after: Instant, + ): NetworkWorksiteChangesResult } private val worksiteCoreDataFields = listOf( @@ -688,4 +697,7 @@ class DataApiClient @Inject constructor( override suspend fun getTeams(incidentId: Long?, limit: Int, offset: Int) = networkApi.getTeams(incidentId, limit, offset) + + override suspend fun getWorksiteChanges(after: Instant) = + networkApi.getWorksiteChanges(after).changes ?: emptyList() } diff --git a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt index 8bca4d05..1427e2b0 100644 --- a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt +++ b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt @@ -3,14 +3,12 @@ package com.crisiscleanup.core.testing.model import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.IncidentCoordinateBoundsNone -import com.crisiscleanup.core.model.data.SyncAttempt import com.crisiscleanup.core.model.data.UserData import com.crisiscleanup.core.model.data.WorksiteSortBy val UserDataNone = UserData( darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, shouldHideOnboarding = false, - syncAttempt = SyncAttempt(0, 0, 0), selectedIncidentId = EmptyIncident.id, languageKey = "", tableViewSortBy = WorksiteSortBy.None, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt index c21ae94a..6ec55425 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt @@ -415,6 +415,7 @@ internal class CaseEditorDataLoader( if (worksite.id > 0 && (networkId > 0 || localWorksite.localChanges.isLocalModified) ) { + // TODO Delete worksite if is not exists on backend and notify user Worksite no longer exists isRefreshingWorksite.value = true if (worksiteChangeRepository.trySyncWorksite(worksite.id) && networkId > 0 diff --git a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt index 3a64d580..32fff771 100644 --- a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt +++ b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt @@ -170,6 +170,7 @@ private fun IncidentWorksitesCacheScreen( IncidentCacheStage.WorksitesAdditional -> t("appCache.syncing_additional_case_data") IncidentCacheStage.ActiveIncident -> t("appCache.syncing_active_incident") IncidentCacheStage.ActiveIncidentOrganization -> t("appCache.syncing_organizations_in_incident") + IncidentCacheStage.WorksitesChangedIncident -> t("~~Syncing Cases with changed Incidents") IncidentCacheStage.End -> t("appCache.sync_finished") } Text(syncStageMessage) From 459fd1cf19321984a5dd8e3028dac2285debc64b Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 2 Sep 2025 14:03:46 -0400 Subject: [PATCH 15/23] Update recently viewed Worksite Incident as changed --- .../database/TestCrisisCleanupDatabase.kt | 9 +++++++++ .../dao/WorksiteSyncReconciliationTest.kt | 20 +++++++++++++++++++ .../core/database/dao/RecentWorksiteDao.kt | 4 ++++ .../core/database/dao/WorksiteDaoPlus.kt | 2 ++ 4 files changed, 35 insertions(+) diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt index 7d3644c3..58269fac 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.Database import androidx.room.Query import androidx.room.Transaction import androidx.room.TypeConverters +import com.crisiscleanup.core.database.dao.RecentWorksiteDao import com.crisiscleanup.core.database.dao.fts.IncidentFtsEntity import com.crisiscleanup.core.database.dao.fts.IncidentOrganizationFtsEntity import com.crisiscleanup.core.database.dao.fts.WorksiteTextFtsEntity @@ -109,6 +110,7 @@ abstract class TestCrisisCleanupDatabase : CrisisCleanupDatabase() { abstract fun testWorkTypeDao(): TestWorkTypeDao abstract fun testWorksiteChangeDao(): TestWorksiteChangeDao abstract fun testWorkTypeRequestDao(): TestWorkTypeRequestDao + abstract fun testRecentWorksiteDao(): TestRecentWorksiteDao } @Dao @@ -269,3 +271,10 @@ interface TestWorkTypeRequestDao { @Query("SELECT id, network_id FROM worksite_work_type_requests WHERE worksite_id=:worksiteId") fun getNetworkedIdMap(worksiteId: Long): List } + +@Dao +interface TestRecentWorksiteDao : RecentWorksiteDao { + @Transaction + @Query("SELECT * FROM recent_worksites ORDER BY id") + fun getRecentWorksites(): List +} \ No newline at end of file diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt index bf0442b0..e51e2540 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt @@ -1,6 +1,7 @@ package com.crisiscleanup.core.database.dao import com.crisiscleanup.core.database.TestCrisisCleanupDatabase +import com.crisiscleanup.core.database.TestRecentWorksiteDao import com.crisiscleanup.core.database.TestUtil import com.crisiscleanup.core.database.TestUtil.testAppLogger import com.crisiscleanup.core.database.TestUtil.testSyncLogger @@ -8,6 +9,7 @@ import com.crisiscleanup.core.database.TestWorksiteDao import com.crisiscleanup.core.database.WorksiteTestUtil import com.crisiscleanup.core.database.WorksiteTestUtil.testIncidents import com.crisiscleanup.core.database.model.IncidentWorksiteIds +import com.crisiscleanup.core.database.model.RecentWorksiteEntity import com.crisiscleanup.core.database.model.WorksiteEntity import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock @@ -23,6 +25,7 @@ class WorksiteSyncReconciliationTest { private lateinit var worksiteDao: TestWorksiteDao private lateinit var worksiteDaoPlus: WorksiteDaoPlus + private lateinit var recentWorksiteDao: TestRecentWorksiteDao private val syncLogger = testSyncLogger() private val appLogger = testAppLogger() @@ -32,6 +35,7 @@ class WorksiteSyncReconciliationTest { db = TestUtil.getTestDatabase() worksiteDao = db.testWorksiteDao() worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) + recentWorksiteDao = db.testRecentWorksiteDao() } @Before @@ -84,6 +88,15 @@ class WorksiteSyncReconciliationTest { @Test fun syncNetworkChangedIncidents() = runTest { + val viewedAt = Instant.fromEpochSeconds(1756835957) + val recentViews = listOf( + RecentWorksiteEntity(4, 23, viewedAt), + RecentWorksiteEntity(1, 23, viewedAt), + ) + for (recent in recentViews) { + recentWorksiteDao.upsert(recent) + } + fun makeIncidentWorksiteIds(incidentId: Long, networkWorksiteId: Long) = IncidentWorksiteIds( incidentId = incidentId, @@ -118,6 +131,13 @@ class WorksiteSyncReconciliationTest { assertEquals(orderedChanges, worksiteIdsA) val worksiteIdsB = worksiteDao.getRootWorksiteEntities() assertEquals(orderedChanges, worksiteIdsB) + + val recents = recentWorksiteDao.getRecentWorksites() + val expectedRecents = listOf( + RecentWorksiteEntity(1, 1, viewedAt), + RecentWorksiteEntity(4, 23, viewedAt), + ) + assertEquals(expectedRecents, recents) } @Test diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt index d1c6eaf8..38a4077f 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt @@ -47,4 +47,8 @@ interface RecentWorksiteDao { @Upsert fun upsert(recentWorksite: RecentWorksiteEntity) + + @Transaction + @Query("UPDATE recent_worksites SET incident_id=:incidentId WHERE id=:id") + fun syncUpdateRecentWorksiteIncident(id: Long, incidentId: Long) } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt index 4e67f2e2..8e616b97 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt @@ -862,11 +862,13 @@ class WorksiteDaoPlus @Inject constructor( changedIncidentWorksites.addAll(changed) } + val recentDao = db.recentWorksiteDao() for (changed in changedIncidentWorksites) { val id = changed.worksiteId val incidentId = changed.incidentId worksiteDao.syncUpdateWorksiteRootIncident(id, incidentId) worksiteDao.syncUpdateWorksiteIncident(id, incidentId) + recentDao.syncUpdateRecentWorksiteIncident(id, incidentId) } changedIncidentWorksites From add5eb439f2d828fda1f1954aec6a7d04418e04b Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 2 Sep 2025 16:54:21 -0400 Subject: [PATCH 16/23] Updates from cross development --- .../core/data/repository/IncidentCacheRepository.kt | 4 +++- .../core/database/TestCrisisCleanupDatabase.kt | 2 +- .../database/dao/WorksiteSyncReconciliationTest.kt | 2 +- .../core/network/model/NetworkWorksiteChange.kt | 1 + .../core/network/retrofit/DataApiClient.kt | 11 +++++++++-- .../incidentcache/ui/IncidentWorksitesCacheScreen.kt | 2 +- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index b2bd0cad..b5fc3073 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -498,6 +498,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( logStage(incidentId, IncidentCacheStage.WorksitesChangedIncident) updateChangedIncidentWorksites( + incidentId, syncPlan.restartCache, syncPreferences.lastReconciled, ) @@ -1295,6 +1296,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( } private suspend fun updateChangedIncidentWorksites( + incidentId: Long, restartCache: Boolean, lastReconciled: Instant, ) { @@ -1314,7 +1316,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( invalidWorksiteIds, ) if (changedIncidents.isNotEmpty()) { - syncLogger.log("${changedIncidents.size} Cases changed Incidents.") + logStage(incidentId, IncidentCacheStage.WorksitesChangedIncident, "${changedIncidents.size} Cases changed Incidents.") } incidentCachePreferences.setLastReconciled(reconcileStart) diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt index 58269fac..f9bcabb0 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt @@ -277,4 +277,4 @@ interface TestRecentWorksiteDao : RecentWorksiteDao { @Transaction @Query("SELECT * FROM recent_worksites ORDER BY id") fun getRecentWorksites(): List -} \ No newline at end of file +} diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt index e51e2540..73c66778 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt @@ -136,7 +136,7 @@ class WorksiteSyncReconciliationTest { val expectedRecents = listOf( RecentWorksiteEntity(1, 1, viewedAt), RecentWorksiteEntity(4, 23, viewedAt), - ) + ) assertEquals(expectedRecents, recents) } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt index 2fc780b7..00feb3f5 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class NetworkWorksiteChangesResult( + val errors: List? = null, val error: String? = null, val changes: List? = null, ) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 877a5845..2dca7561 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -22,6 +22,7 @@ import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkUsersResult import com.crisiscleanup.core.network.model.NetworkWorkTypeRequestResult import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteChangesResult import com.crisiscleanup.core.network.model.NetworkWorksiteLocationSearchResult import com.crisiscleanup.core.network.model.NetworkWorksitesCoreDataResult @@ -698,6 +699,12 @@ class DataApiClient @Inject constructor( override suspend fun getTeams(incidentId: Long?, limit: Int, offset: Int) = networkApi.getTeams(incidentId, limit, offset) - override suspend fun getWorksiteChanges(after: Instant) = - networkApi.getWorksiteChanges(after).changes ?: emptyList() + override suspend fun getWorksiteChanges(after: Instant): List { + val result = networkApi.getWorksiteChanges(after) + result.errors?.tryThrowException() + result.error?.let { errorMessage -> + throw Exception(errorMessage) + } + return result.changes ?: emptyList() + } } diff --git a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt index 32fff771..e5085207 100644 --- a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt +++ b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt @@ -170,7 +170,7 @@ private fun IncidentWorksitesCacheScreen( IncidentCacheStage.WorksitesAdditional -> t("appCache.syncing_additional_case_data") IncidentCacheStage.ActiveIncident -> t("appCache.syncing_active_incident") IncidentCacheStage.ActiveIncidentOrganization -> t("appCache.syncing_organizations_in_incident") - IncidentCacheStage.WorksitesChangedIncident -> t("~~Syncing Cases with changed Incidents") + IncidentCacheStage.WorksitesChangedIncident -> t("~~Syncing Cases with changed Incidents...") IncidentCacheStage.End -> t("appCache.sync_finished") } Text(syncStageMessage) From 0d00537bfc47b6a3f61191503ead811621444779 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 2 Sep 2025 19:07:06 -0400 Subject: [PATCH 17/23] Sync data properly on first install and account change --- .../core/data/IncidentSelector.kt | 25 +--------------- .../OfflineFirstIncidentsRepository.kt | 21 ++++++++----- .../LocalAppPreferencesDataSource.kt | 30 ------------------- .../core/model/data/AccountData.kt | 7 +++++ .../crisiscleanup/core/model/data/Incident.kt | 12 +++++--- 5 files changed, 30 insertions(+), 65 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt index 3434f2bc..54ba261c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt @@ -12,11 +12,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -46,11 +44,7 @@ class IncidentSelectManager @Inject constructor( ::Pair, ) .mapLatest { (incidents, accountData) -> - if (accountData.isCrisisCleanupAdmin) { - incidents - } else { - incidents.filter { accountData.approvedIncidents.contains(it.id) } - } + accountData.filterApproved(incidents) } private val preferencesIncidentId = @@ -65,7 +59,6 @@ class IncidentSelectManager @Inject constructor( ) .map { (selectedId, incidents) -> incidents.firstOrNull { it.id == selectedId } - ?: incidents.firstOrNull() ?: EmptyIncident } @@ -102,22 +95,6 @@ class IncidentSelectManager @Inject constructor( started = subscribedReplay(), ) - init { - combine( - preferencesIncidentId, - incidentsSource, - ::Pair, - ) - .onEach { (selectedId, incidents) -> - val selectedIncident = incidents.find { it.id == selectedId } ?: EmptyIncident - if (selectedIncident == EmptyIncident && incidents.isNotEmpty()) { - val firstIncident = incidents[0] - appPreferencesRepository.setSelectedIncident(firstIncident.id) - } - } - .launchIn(coroutineScope) - } - override fun selectIncident(incident: Incident) { coroutineScope.launch { submitIncidentChange(incident) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 2b081aa8..1063dbb3 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -18,7 +18,9 @@ import com.crisiscleanup.core.database.dao.LocationDaoPlus import com.crisiscleanup.core.database.dao.fts.getMatchingIncidents import com.crisiscleanup.core.database.model.PopulatedIncident import com.crisiscleanup.core.database.model.asExternalModel +import com.crisiscleanup.core.datastore.AccountInfoDataSource import com.crisiscleanup.core.datastore.LocalAppPreferencesDataSource +import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.INCIDENT_ORGANIZATIONS_STABLE_MODEL_BUILD_VERSION import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.model.data.IncidentIdNameType @@ -30,6 +32,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -47,6 +50,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( private val incidentOrganizationDao: IncidentOrganizationDao, private val incidentOrganizationsSyncer: IncidentOrganizationsSyncer, private val appPreferences: LocalAppPreferencesDataSource, + private val accountInfoDataSource: AccountInfoDataSource, inputValidator: InputValidator, @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @@ -214,13 +218,16 @@ class OfflineFirstIncidentsRepository @Inject constructor( } override suspend fun pullIncidents(force: Boolean) = coroutineScope { - var isSuccessful = false - try { - syncInternal(force) - isSuccessful = true - } finally { - // Treat coroutine cancellation as unsuccessful for now - appPreferences.setSyncAttempt(isSuccessful) + syncInternal(force) + + val selectedIncidentId = appPreferences.userData.first().selectedIncidentId + if (selectedIncidentId == EmptyIncident.id) { + val incidents = getIncidentsList() + val accountData = accountInfoDataSource.accountData.first() + val approvedIncidents = accountData.filterApproved(incidents) + approvedIncidents.firstOrNull()?.let { firstIncident -> + appPreferences.setSelectedIncident(firstIncident.id) + } } } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index 715a5bd7..9a167752 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -8,7 +8,6 @@ import com.crisiscleanup.core.model.data.UserData import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.worksiteSortByFromLiteral import kotlinx.coroutines.flow.map -import kotlinx.datetime.Clock import javax.inject.Inject /** @@ -99,35 +98,6 @@ class LocalAppPreferencesDataSource @Inject constructor( } } - suspend fun setSyncAttempt( - isSuccessful: Boolean, - attemptedSeconds: Long = Clock.System.now().epochSeconds, - ) { - userPreferences.updateData { - val builder = SyncAttemptProto.newBuilder(it.syncAttempt) - if (isSuccessful) { - builder.successfulSeconds = attemptedSeconds - builder.attemptedCounter = 0 - } else { - builder.attemptedCounter++ - } - builder.attemptedSeconds = attemptedSeconds - val attempt = builder.build() - - it.copy { - syncAttempt = attempt - } - } - } - - suspend fun clearSyncData() { - userPreferences.updateData { - it.copy { - syncAttempt = SyncAttemptProto.newBuilder().build() - } - } - } - suspend fun setSelectedIncident(id: Long) { userPreferences.updateData { it.copy { selectedIncidentId = id } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt index f437ec5c..829a1fc4 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt @@ -52,6 +52,13 @@ data class AccountData( val isAccessTokenExpired: Boolean get() = tokenExpiry <= Clock.System.now().minus(1.minutes) + + fun filterApproved(incidents: List) = + if (isCrisisCleanupAdmin) { + incidents + } else { + incidents.filter { approvedIncidents.contains(it.id) } + } } val emptyOrgData = OrgData(0, "") diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt index 19f768b6..3861f486 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt @@ -2,8 +2,12 @@ package com.crisiscleanup.core.model.data import kotlinx.datetime.Instant +interface IncidentIdProvider { + val id: Long +} + data class Incident( - val id: Long, + override val id: Long, val name: String, val shortName: String, val caseLabel: String, @@ -15,7 +19,7 @@ data class Incident( val disaster: Disaster = disasterFromLiteral(disasterLiteral), val displayLabel: String = if (caseLabel.isBlank()) name else "$caseLabel: $name", val startAt: Instant? = null, -) { +) : IncidentIdProvider { val formFieldLookup: Map by lazy { formFields.associateBy { it.fieldKey } } @@ -75,12 +79,12 @@ data class IncidentFormField( } data class IncidentIdNameType( - val id: Long, + override val id: Long, val name: String, val shortName: String, val disasterLiteral: String, val disaster: Disaster = disasterFromLiteral(disasterLiteral), -) +) : IncidentIdProvider val Incident.idNameType: IncidentIdNameType get() = IncidentIdNameType( From f4274b0562efd24ea63e32419ae095a546d9c023 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 3 Sep 2025 15:10:37 -0400 Subject: [PATCH 18/23] Log additional information when deleting invalidated Cases --- .../repository/IncidentCacheRepository.kt | 10 ++++-- .../OfflineFirstWorksitesRepository.kt | 8 +++-- .../dao/WorksiteSyncReconciliationTest.kt | 34 +++++++++++------- .../core/database/dao/WorksiteDaoPlus.kt | 35 ++++++++++++++----- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index b5fc3073..10396ac0 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -1311,12 +1311,16 @@ class IncidentWorksitesCacheRepository @Inject constructor( val (valid, invalid) = worksiteChanges.split { it.invalidatedAt == null } val invalidWorksiteIds = invalid.map(NetworkWorksiteChange::worksiteId) - val changedIncidents = worksitesRepository.processReconciliation( + val localChanges = worksitesRepository.processReconciliation( valid.toList(), invalidWorksiteIds, ) - if (changedIncidents.isNotEmpty()) { - logStage(incidentId, IncidentCacheStage.WorksitesChangedIncident, "${changedIncidents.size} Cases changed Incidents.") + if (localChanges.isNotEmpty()) { + logStage( + incidentId, + IncidentCacheStage.WorksitesChangedIncident, + "${localChanges.size} Cases changed Incidents or were deleted.", + ) } incidentCachePreferences.setLastReconciled(reconcileStart) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index 2e023038..e8bb6761 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt @@ -349,9 +349,11 @@ class OfflineFirstWorksitesRepository @Inject constructor( networkWorksiteId = it.worksiteId, ) } - val changedIncidents = worksiteDaoPlus.syncNetworkChangedIncidents(validIds) - worksiteDaoPlus.syncDeletedWorksites(invalidatedNetworkWorksiteIds) + val worksitesChanged = worksiteDaoPlus.syncNetworkChangedIncidents(validIds) + val worksitesDeleted = worksiteDaoPlus.syncDeletedWorksites(invalidatedNetworkWorksiteIds) - return changedIncidents + return worksitesChanged.toMutableList().apply { + addAll(worksitesDeleted) + } } } diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt index 73c66778..b183c312 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt @@ -97,7 +97,7 @@ class WorksiteSyncReconciliationTest { recentWorksiteDao.upsert(recent) } - fun makeIncidentWorksiteIds(incidentId: Long, networkWorksiteId: Long) = + fun makeChangeIds(incidentId: Long, networkWorksiteId: Long) = IncidentWorksiteIds( incidentId = incidentId, worksiteId = 0, @@ -106,26 +106,28 @@ class WorksiteSyncReconciliationTest { val changes = worksiteDaoPlus.syncNetworkChangedIncidents( listOf( - makeIncidentWorksiteIds(1, 534), - makeIncidentWorksiteIds(1, 987), - makeIncidentWorksiteIds(23, 1654), + makeChangeIds(1, 534), + makeChangeIds(1, 8921), + makeChangeIds(1, 987), + makeChangeIds(1, 4986), + makeChangeIds(23, 1654), ), stepInterval = 2, ) val expectedChanges = listOf( - IncidentWorksiteIds(1, 1, 534), - IncidentWorksiteIds(1, 5, 987), - IncidentWorksiteIds(23, 3, 1654), + IncidentWorksiteIds(23, 1, 534), + IncidentWorksiteIds(23, 5, 987), + IncidentWorksiteIds(456, 3, 1654), ) assertEquals(expectedChanges, changes) val orderedChanges = listOf( - expectedChanges[0], + IncidentWorksiteIds(1, 1, 534), IncidentWorksiteIds(1, 2, 48), - expectedChanges[2], + IncidentWorksiteIds(23, 3, 1654), IncidentWorksiteIds(23, 4, 9), - expectedChanges[1], + IncidentWorksiteIds(1, 5, 987), ) val worksiteIdsA = worksiteDao.getWorksiteEntities() assertEquals(orderedChanges, worksiteIdsA) @@ -142,13 +144,19 @@ class WorksiteSyncReconciliationTest { @Test fun syncDeletedWorksites() = runTest { - worksiteDaoPlus.syncDeletedWorksites( - listOf(987, 1654, 48), + val changes = worksiteDaoPlus.syncDeletedWorksites( + listOf(987, 9, 54, 13, 654, 7895, 48), stepInterval = 2, ) + val expectedChanges = listOf( + IncidentWorksiteIds(23, 5, 987), + IncidentWorksiteIds(23, 4, 9), + IncidentWorksiteIds(1, 2, 48), + ) + assertEquals(expectedChanges, changes) val worksites = worksiteDao.getWorksites() val networkWorksiteIds = worksites.map { it.entity.networkId } - assertEquals(listOf(9L, 534), networkWorksiteIds) + assertEquals(listOf(534L, 1654), networkWorksiteIds) } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt index 8e616b97..a1775e62 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt @@ -843,23 +843,29 @@ class WorksiteDaoPlus @Inject constructor( changeCandidates: List, stepInterval: Int = 100, ) = db.withTransaction { - val changedIncidentWorksites = mutableListOf() + val changedWorksites = mutableListOf() val worksiteDao = db.worksiteDao() + val changedIncidentWorksites = mutableListOf() val iStep = stepInterval.coerceAtLeast(1) for (i in changeCandidates.indices step iStep) { val iEnd = (i + iStep).coerceAtMost(changeCandidates.size) - val chunk = changeCandidates.subList(i, iEnd) - val queryIds = chunk.map(IncidentWorksiteIds::networkWorksiteId) + val candidatesChunk = changeCandidates.subList(i, iEnd) + val queryIds = candidatesChunk.map(IncidentWorksiteIds::networkWorksiteId) val localLookup = worksiteDao.getWorksiteIds(queryIds) .associateBy(IncidentWorksiteIds::networkWorksiteId) - val changed = chunk.mapNotNull { candidate -> + val chunkChanges = candidatesChunk.mapNotNull { candidate -> localLookup[candidate.networkWorksiteId]?.let { localMatch -> - return@mapNotNull candidate.copy(worksiteId = localMatch.worksiteId) + if (candidate.incidentId != localMatch.incidentId) { + changedWorksites.add(localMatch) + return@mapNotNull candidate.copy( + worksiteId = localMatch.worksiteId, + ) + } } null } - changedIncidentWorksites.addAll(changed) + changedIncidentWorksites.addAll(chunkChanges) } val recentDao = db.recentWorksiteDao() @@ -871,16 +877,27 @@ class WorksiteDaoPlus @Inject constructor( recentDao.syncUpdateRecentWorksiteIncident(id, incidentId) } - changedIncidentWorksites + changedWorksites } suspend fun syncDeletedWorksites(networkIds: List, stepInterval: Int = 100) = db.withTransaction { + val deletedWorksites = mutableListOf() + val iStep = stepInterval.coerceAtLeast(1) + val worksiteDao = db.worksiteDao() for (i in networkIds.indices step iStep) { val iEnd = (i + iStep).coerceAtMost(networkIds.size) - val deleteIds = networkIds.subList(i, iEnd) - db.worksiteDao().deleteNetworkWorksites(deleteIds) + val idChunk = networkIds.subList(i, iEnd) + val localLookup = worksiteDao.getWorksiteIds(idChunk) + .associateBy(IncidentWorksiteIds::networkWorksiteId) + if (localLookup.isNotEmpty()) { + val deleteIds = localLookup.keys + db.worksiteDao().deleteNetworkWorksites(deleteIds) + deletedWorksites.addAll(idChunk.mapNotNull { localLookup[it] }) + } } + + deletedWorksites } } From b7ace4b832f25463beef49c2402f3a8a471b0c89 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 3 Sep 2025 17:03:12 -0400 Subject: [PATCH 19/23] Fix app update available icon positioning --- .../com/crisiscleanup/sandbox/SandboxApp.kt | 8 +- .../sandbox/navigation/SandboxNavigation.kt | 10 ++ .../crisiscleanup/sandbox/ui/RowBadgeView.kt | 116 ++++++++++++++++++ app/build.gradle.kts | 2 +- .../core/designsystem/theme/Dimensions.kt | 1 + .../feature/menu/ui/MenuScreen.kt | 42 +++---- 6 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt index 038fcaab..617f75d7 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt @@ -24,13 +24,14 @@ import androidx.navigation.NavController import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy -import com.crisiscleanup.sandbox.navigation.ASYNC_IMAGE_ROUTE +import com.crisiscleanup.sandbox.navigation.ROW_BADGE_ROUTE import com.crisiscleanup.sandbox.navigation.SandboxNavHost import com.crisiscleanup.sandbox.navigation.navigateToAsyncImage import com.crisiscleanup.sandbox.navigation.navigateToBottomNav import com.crisiscleanup.sandbox.navigation.navigateToCheckboxes import com.crisiscleanup.sandbox.navigation.navigateToChips import com.crisiscleanup.sandbox.navigation.navigateToMultiImage +import com.crisiscleanup.sandbox.navigation.navigateToRowBadge import com.crisiscleanup.sandbox.navigation.navigateToSingleImage @Composable @@ -66,7 +67,7 @@ fun SandboxApp( SandboxNavHost( appState.navController, appState::onBack, - ASYNC_IMAGE_ROUTE, + ROW_BADGE_ROUTE, ) } } @@ -102,6 +103,9 @@ fun RootRoute(navController: NavController) { CrisisCleanupTextButton(text = "Async image") { navController.navigateToAsyncImage() } + CrisisCleanupTextButton(text = "Row badge") { + navController.navigateToRowBadge() + } } } } diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt index 4cd9cb05..af8945a1 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt @@ -11,6 +11,7 @@ import com.crisiscleanup.sandbox.ui.BottomNavRoute import com.crisiscleanup.sandbox.ui.CheckboxesRoute import com.crisiscleanup.sandbox.ui.ChipsRoute import com.crisiscleanup.sandbox.ui.MultiImageRoute +import com.crisiscleanup.sandbox.ui.RowBadgeView import com.crisiscleanup.sandbox.ui.SingleImageRoute const val ROOT_ROUTE = "root" @@ -20,6 +21,7 @@ private const val BOTTOM_NAV_ROUTE = "bottom-nav" const val SINGLE_IMAGE_ROUTE = "single-image" const val MULTI_IMAGE_ROUTE = "multi-image" const val ASYNC_IMAGE_ROUTE = "async-image" +const val ROW_BADGE_ROUTE = "row-badge" fun NavController.navigateToBottomNav() { this.navigate(BOTTOM_NAV_ROUTE) @@ -45,6 +47,10 @@ fun NavController.navigateToAsyncImage() { this.navigate(ASYNC_IMAGE_ROUTE) } +fun NavController.navigateToRowBadge() { + this.navigate(ROW_BADGE_ROUTE) +} + @Composable fun SandboxNavHost( navController: NavHostController, @@ -82,5 +88,9 @@ fun SandboxNavHost( composable(ASYNC_IMAGE_ROUTE) { AsyncImageView() } + + composable(ROW_BADGE_ROUTE) { + RowBadgeView() + } } } diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt new file mode 100644 index 00000000..75039ee6 --- /dev/null +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt @@ -0,0 +1,116 @@ +package com.crisiscleanup.sandbox.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +private fun RowScope.BadgedText( + alignment: Alignment, + text: String, +) { + BadgedBox( + { + Badge( + Modifier + .align(alignment) + .size(20.dp), + containerColor = Color.Red, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + } + }, + Modifier + .background(Color.LightGray) + .weight(1f), + ) { + Text( + text, + Modifier + .align(Alignment.CenterStart) + .background(Color.Yellow), + ) + } +} + +@Composable +private fun RowBadge( + alignment: Alignment, + text: String, + buttonText: String = "Press me", +) { + Row( + Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BadgedText(alignment, text) + Button({}) { + Text(buttonText) + } + } +} + +@Composable +private fun ReverseRowBadge( + alignment: Alignment, + text: String, + buttonText: String = "Press me", +) { + Row( + Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button({}) { + Text(buttonText) + } + BadgedText(alignment, text) + } +} + +@Composable +fun RowBadgeView() { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + RowBadge(Alignment.TopStart, "Top start") + RowBadge(Alignment.TopCenter, "Top center") + RowBadge(Alignment.TopEnd, "Top end") + RowBadge(Alignment.BottomEnd, "Bottom end") + RowBadge(Alignment.BottomCenter, "Bottom center") + RowBadge(Alignment.BottomStart, "Bottom start") + ReverseRowBadge(Alignment.TopStart, "Top start") + ReverseRowBadge(Alignment.TopCenter, "Top center") + ReverseRowBadge(Alignment.TopEnd, "Top end") + ReverseRowBadge(Alignment.BottomEnd, "Bottom end") + ReverseRowBadge(Alignment.BottomCenter, "Bottom center") + ReverseRowBadge(Alignment.BottomStart, "Bottom start") + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d105a693..c69aab90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 273 + val buildVersion = 274 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt index e6227d2e..12bb52b9 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt @@ -29,6 +29,7 @@ data class Dimensions( val isLandscape: Boolean = false, val isPortrait: Boolean = true, val isListDetailWidth: Boolean = false, + val contentMaxWidth: Dp = 600.dp, val buttonSpinnerSize: Dp = 24.dp, ) { diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index f638c950..3e62a515 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -62,6 +61,7 @@ import com.crisiscleanup.core.designsystem.component.OpenSettingsDialog import com.crisiscleanup.core.designsystem.component.actionHeight import com.crisiscleanup.core.designsystem.component.actionRoundCornerShape import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.LocalDimensions import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.cardContainerColor import com.crisiscleanup.core.designsystem.theme.listItemBottomPadding @@ -196,7 +196,7 @@ private fun MenuScreen( val incidentDataCacheMetrics by viewModel.incidentDataCacheMetrics.collectAsStateWithLifecycle() val hasSpeedNotAdaptive = incidentDataCacheMetrics.hasSpeedNotAdaptive - Column { + Column(horizontalAlignment = Alignment.CenterHorizontally) { AppTopBar( incidentDropdownModifier = incidentDropdownModifier, accountToggleModifier = accountToggleModifier @@ -251,7 +251,8 @@ private fun MenuScreen( } LazyColumn( - Modifier.weight(1f), + Modifier.weight(1f) + .sizeIn(maxWidth = LocalDimensions.current.contentMaxWidth), state = lazyListState, ) { hotlineItems( @@ -729,33 +730,24 @@ private fun AppUpdateView() { horizontalArrangement = listItemSpacedBy, verticalAlignment = Alignment.CenterVertically, ) { - var badgeOffsetX by remember { mutableStateOf(0.dp) } - val localDensity = LocalDensity.current - BadgedBox( - badge = { - Badge( - Modifier - .size(20.dp) - .offset(x = badgeOffsetX), - containerColor = primaryOrangeColor, - ) { - // TODO: Match content color in menu badge - Icon( - imageVector = CrisisCleanupIcons.AppUpdateAvailable, - contentDescription = null, - ) - } - }, + Box( Modifier.weight(1f), ) { Text( t("~~A new version of the app is available"), - Modifier.onGloballyPositioned { - badgeOffsetX = with(localDensity) { - -it.size.width.div(2).toDp().plus(4.dp) - } - }, + Modifier.align(Alignment.CenterStart), ) + Badge( + Modifier.size(20.dp) + .offset(x = (-10).dp, y = (-10).dp), + containerColor = primaryOrangeColor, + ) { + // TODO Match content color in menu badge + Icon( + imageVector = CrisisCleanupIcons.AppUpdateAvailable, + contentDescription = null, + ) + } } val context = LocalContext.current From 0a44f9e0a600669c1354f131463c6529f7c0f4d4 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 3 Sep 2025 17:14:18 -0400 Subject: [PATCH 20/23] Update icon color --- .../main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index 3e62a515..697313d8 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -742,10 +742,10 @@ private fun AppUpdateView() { .offset(x = (-10).dp, y = (-10).dp), containerColor = primaryOrangeColor, ) { - // TODO Match content color in menu badge Icon( imageVector = CrisisCleanupIcons.AppUpdateAvailable, contentDescription = null, + tint = Color.White, ) } } From efd873777e9377d3a6a8916df5480fcbb3009dbd Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 4 Sep 2025 16:45:05 -0400 Subject: [PATCH 21/23] Cache Incident Worksites consistently --- .../repository/IncidentCacheRepository.kt | 32 ++++++++++++------- .../network/CrisisCleanupNetworkDataSource.kt | 6 ++++ .../core/network/retrofit/DataApiClient.kt | 8 +++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index 10396ac0..7f16b4c9 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -883,8 +883,13 @@ class IncidentWorksitesCacheRepository @Inject constructor( statsUpdater, downloadSpeedTracker, getTotalCaseCount = null, - { count: Int, before: Instant -> - networkDataSource.getWorksitesPageBefore(incidentId, count, before) + { count: Int, offset: Int, before: Instant -> + networkDataSource.getWorksitesPageBefore( + incidentId, + pageCount = count, + updatedBefore = before, + offset = offset, + ) }, { worksites: List -> saveWorksites(worksites, statsUpdater) @@ -935,7 +940,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( statsUpdater: IncidentDataPullStatsUpdater, downloadSpeedTracker: CountTimeTracker, getTotalCaseCount: (suspend () -> Int)?, - getNetworkData: suspend (Int, Instant) -> T, + getNetworkData: suspend (Int, Int, Instant) -> T, saveToDb: suspend (List) -> Unit, ) where T : WorksiteDataResult, U : WorksiteDataSubset = coroutineScope { var isSlowDownload: Boolean? = null @@ -944,9 +949,10 @@ class IncidentWorksitesCacheRepository @Inject constructor( log("Downloading Worksites before") - var queryCount = if (isPaused) 100 else 1000 - val maxQueryCount = getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) - var beforeTimeMarker = timeMarkers.before + val queryCount = + if (isPaused) 100 else getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) + var queryOffset = 0 + val beforeTimeMarker = timeMarkers.before var savedWorksiteIds = emptySet() var initialCount = -1 var savedCount = 0 @@ -958,6 +964,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( // TODO Edge case where paging data breaks where Cases are equally updated_at val result = getNetworkData( queryCount, + queryOffset, beforeTimeMarker, ) @@ -1006,16 +1013,16 @@ class IncidentWorksitesCacheRepository @Inject constructor( savedWorksiteIds = networkData.map { it.id }.toSet() - queryCount = (queryCount * 2).coerceAtMost(maxQueryCount) - beforeTimeMarker = networkData.last().updatedAt + queryOffset += queryCount + val lastTimeMarker = networkData.last().updatedAt.plus(1.minutes) if (stage == IncidentCacheStage.WorksitesCore) { - syncParameterDao.updateUpdatedBefore(incidentId, beforeTimeMarker) + syncParameterDao.updateUpdatedBefore(incidentId, lastTimeMarker) } else { - syncParameterDao.updateAdditionalUpdatedBefore(incidentId, beforeTimeMarker) + syncParameterDao.updateAdditionalUpdatedBefore(incidentId, lastTimeMarker) } - log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) before, back to $beforeTimeMarker") + log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) before, back to $beforeTimeMarker ($queryOffset-$queryCount)") } if (isPaused) { @@ -1197,11 +1204,12 @@ class IncidentWorksitesCacheRepository @Inject constructor( statsUpdater, downloadSpeedTracker, getTotalCaseCount = { worksitesRepository.getWorksitesCount(incidentId) }, - { count: Int, before: Instant -> + { count: Int, offset: Int, before: Instant -> networkDataSource.getWorksitesFlagsFormDataPageBefore( incidentId, count, before, + offset = offset, ) }, { worksites: List -> diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index 07660c02..35a2791b 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -102,17 +102,20 @@ interface CrisisCleanupNetworkDataSource { pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int = 0, ): NetworkWorksitesPageResult suspend fun getWorksitesPageBefore( incidentId: Long, pageCount: Int, updatedBefore: Instant, + offset: Int, ): NetworkWorksitesPageResult = getWorksitesPageUpdatedAt( incidentId, pageCount, updatedBefore, true, + offset = offset, ) suspend fun getWorksitesPageAfter( @@ -131,17 +134,20 @@ interface CrisisCleanupNetworkDataSource { pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int = 0, ): NetworkFlagsFormDataResult suspend fun getWorksitesFlagsFormDataPageBefore( incidentId: Long, pageCount: Int, updatedBefore: Instant, + offset: Int, ) = getWorksitesFlagsFormDataPage( incidentId, pageCount, updatedBefore, true, + offset = offset, ) suspend fun getWorksitesFlagsFormDataPageAfter( diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 2dca7561..959ccf70 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -223,6 +223,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") pageCount: Int, + @Query("offset") + offset: Int, @Query("updated_at__lt") updatedBefore: Instant, @Query("sort") @@ -317,6 +319,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") limit: Int, + @Query("offset") + offset: Int, @Query("updated_at__lt") updatedAtBefore: Instant, @Query("sort") @@ -530,11 +534,13 @@ class DataApiClient @Inject constructor( pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int, ): NetworkWorksitesPageResult { val result = if (isPagingBackwards) { networkApi.getWorksitesPageUpdatedBefore( incidentId, pageCount, + offset = offset, updatedAt, "-updated_at", ) @@ -556,11 +562,13 @@ class DataApiClient @Inject constructor( pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int, ): NetworkFlagsFormDataResult { val result = if (isPagingBackwards) { networkApi.getWorksitesFlagsFormDataBefore( incidentId, pageCount, + offset = offset, updatedAt, "-updated_at", ) From 2db0993d0b9b41483c1513a8482eba60080d33ef Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 11:07:07 -0400 Subject: [PATCH 22/23] Page through Cases when caching delta --- app/build.gradle.kts | 2 +- .../repository/IncidentCacheRepository.kt | 40 ++++++++++--------- .../network/CrisisCleanupNetworkDataSource.kt | 8 +++- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c69aab90..0ecbec0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 274 + val buildVersion = 276 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index 7f16b4c9..d11302c3 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -917,8 +917,13 @@ class IncidentWorksitesCacheRepository @Inject constructor( timeMarkers, statsUpdater, downloadSpeedTracker, - { count: Int, after: Instant -> - networkDataSource.getWorksitesPageAfter(incidentId, count, after) + { count: Int, offset: Int, after: Instant -> + networkDataSource.getWorksitesPageAfter( + incidentId, + pageCount = count, + updatedAfter = after, + offset = offset, + ) }, { worksites: List -> saveWorksites(worksites, statsUpdater) @@ -961,7 +966,6 @@ class IncidentWorksitesCacheRepository @Inject constructor( ensureActive() val networkData = downloadSpeedTracker.time { - // TODO Edge case where paging data breaks where Cases are equally updated_at val result = getNetworkData( queryCount, queryOffset, @@ -1047,30 +1051,30 @@ class IncidentWorksitesCacheRepository @Inject constructor( timeMarkers: IncidentDataSyncParameters.SyncTimeMarker, statsUpdater: IncidentDataPullStatsUpdater, downloadSpeedTracker: CountTimeTracker, - getNetworkData: suspend (Int, Instant) -> T, + getNetworkData: suspend (Int, Int, Instant) -> T, saveToDb: suspend (List) -> Unit, ) where T : WorksiteDataResult, U : WorksiteDataSubset = coroutineScope { var isSlowDownload: Boolean? = null fun log(message: String) = logStage(incidentId, stage, message) - var afterTimeMarker = timeMarkers.after - - log("Downloading delta starting at $afterTimeMarker") - - var queryCount = if (isPaused) 100 else 1000 - val maxQueryCount = getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) + val queryCount = + if (isPaused) 100 else getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) + var queryOffset = 0 + val afterTimeMarker = timeMarkers.after var savedWorksiteIds = emptySet() var initialCount = -1 var savedCount = 0 + log("Downloading delta starting at $afterTimeMarker") + do { ensureActive() val networkData = downloadSpeedTracker.time { - // TODO Edge case where paging data breaks where Cases are equally updated_at val result = getNetworkData( queryCount, + queryOffset, afterTimeMarker, ) if (initialCount < 0) { @@ -1091,7 +1095,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( if (networkData.isEmpty()) { updateUpdatedAfter(syncStart) - log("Cached $savedCount/$initialCount after. No Cases after $afterTimeMarker") + log("Cached $savedCount/$initialCount after. No Cases after $afterTimeMarker ($queryOffset-$queryCount)") } else { downloadSpeedTracker.averageSpeed()?.let { val isSlow = it < slowDownloadSpeed @@ -1114,12 +1118,11 @@ class IncidentWorksitesCacheRepository @Inject constructor( savedWorksiteIds = networkData.map { it.id }.toSet() - queryCount = (queryCount * 2).coerceAtMost(maxQueryCount) - afterTimeMarker = networkData.last().updatedAt - - updateUpdatedAfter(afterTimeMarker) + queryOffset += queryCount + val lastTimeMarker = networkData.last().updatedAt.minus(1.minutes) + updateUpdatedAfter(lastTimeMarker) - log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $afterTimeMarker") + log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $afterTimeMarker ($queryOffset-$queryCount)") } if (isPaused) { @@ -1238,11 +1241,12 @@ class IncidentWorksitesCacheRepository @Inject constructor( timeMarkers, statsUpdater, downloadSpeedTracker, - { count: Int, after: Instant -> + { count: Int, offset: Int, after: Instant -> networkDataSource.getWorksitesFlagsFormDataPageAfter( incidentId, count, after, + offset = offset, ) }, { worksites: List -> diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index 35a2791b..c6a8b5ba 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -102,7 +102,7 @@ interface CrisisCleanupNetworkDataSource { pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, - offset: Int = 0, + offset: Int, ): NetworkWorksitesPageResult suspend fun getWorksitesPageBefore( @@ -122,11 +122,13 @@ interface CrisisCleanupNetworkDataSource { incidentId: Long, pageCount: Int, updatedAfter: Instant, + offset: Int, ): NetworkWorksitesPageResult = getWorksitesPageUpdatedAt( incidentId, pageCount, updatedAfter, false, + offset = offset, ) suspend fun getWorksitesFlagsFormDataPage( @@ -134,7 +136,7 @@ interface CrisisCleanupNetworkDataSource { pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, - offset: Int = 0, + offset: Int, ): NetworkFlagsFormDataResult suspend fun getWorksitesFlagsFormDataPageBefore( @@ -154,11 +156,13 @@ interface CrisisCleanupNetworkDataSource { incidentId: Long, pageCount: Int, updatedAfter: Instant, + offset: Int, ) = getWorksitesFlagsFormDataPage( incidentId, pageCount, updatedAfter, false, + offset = offset, ) suspend fun getWorksitesFlagsFormData( From 4f3612863a946291d1f22baa39c93bdc6934c830 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 12:52:42 -0400 Subject: [PATCH 23/23] Fix (caching) delta Cases endpoint with correct parameters --- app/build.gradle.kts | 2 +- .../core/data/repository/IncidentCacheRepository.kt | 11 ++++++----- .../core/network/retrofit/DataApiClient.kt | 6 ++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ecbec0b..0792389e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 276 + val buildVersion = 277 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index d11302c3..e9102a4c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -1017,8 +1017,6 @@ class IncidentWorksitesCacheRepository @Inject constructor( savedWorksiteIds = networkData.map { it.id }.toSet() - queryOffset += queryCount - val lastTimeMarker = networkData.last().updatedAt.plus(1.minutes) if (stage == IncidentCacheStage.WorksitesCore) { syncParameterDao.updateUpdatedBefore(incidentId, lastTimeMarker) @@ -1026,7 +1024,9 @@ class IncidentWorksitesCacheRepository @Inject constructor( syncParameterDao.updateAdditionalUpdatedBefore(incidentId, lastTimeMarker) } - log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) before, back to $beforeTimeMarker ($queryOffset-$queryCount)") + log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) before, back to $lastTimeMarker ($queryOffset-$queryCount)") + + queryOffset += queryCount } if (isPaused) { @@ -1118,11 +1118,12 @@ class IncidentWorksitesCacheRepository @Inject constructor( savedWorksiteIds = networkData.map { it.id }.toSet() - queryOffset += queryCount val lastTimeMarker = networkData.last().updatedAt.minus(1.minutes) updateUpdatedAfter(lastTimeMarker) - log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $afterTimeMarker ($queryOffset-$queryCount)") + log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $lastTimeMarker ($queryOffset-$queryCount)") + + queryOffset += queryCount } if (isPaused) { diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 959ccf70..0eb4b4eb 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -238,6 +238,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") pageCount: Int, + @Query("offset") + offset: Int, @Query("updated_at__gt") updatedAfter: Instant, @Query("sort") @@ -334,6 +336,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") limit: Int, + @Query("offset") + offset: Int, @Query("updated_at__gt") updatedAfter: Instant, @Query("sort") @@ -548,6 +552,7 @@ class DataApiClient @Inject constructor( networkApi.getWorksitesPageUpdatedAfter( incidentId, pageCount, + offset = offset, updatedAt, "updated_at", ) @@ -576,6 +581,7 @@ class DataApiClient @Inject constructor( networkApi.getWorksitesFlagsFormDataAfter( incidentId, pageCount, + offset = offset, updatedAt, "updated_at", )