Skip to content

Commit

Permalink
Merge pull request #85 from CrisisCleanup/production-switchover
Browse files Browse the repository at this point in the history
Production switchover
  • Loading branch information
hueachilles authored Sep 5, 2023
2 parents de8c736 + d1d4bb9 commit 578eec6
Show file tree
Hide file tree
Showing 41 changed files with 687 additions and 169 deletions.
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ plugins {

android {
defaultConfig {
val buildVersion = 142
val buildVersion = 146
applicationId = "com.crisiscleanup"
versionCode = buildVersion
versionName = "0.6.${buildVersion - 140}"
versionName = "0.7.${buildVersion - 140}"

// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.crisiscleanup.core.testing.CrisisCleanupTestRunner"
Expand Down
43 changes: 43 additions & 0 deletions app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.crisiscleanup

import android.content.Intent
import android.net.Uri
import com.crisiscleanup.core.common.event.AuthEventBus
import com.crisiscleanup.core.common.log.AppLogger
import com.crisiscleanup.core.common.log.CrisisCleanupLoggers
import com.crisiscleanup.core.common.log.Logger
import javax.inject.Inject

class ExternalIntentProcessor @Inject constructor(
private val authEventBus: AuthEventBus,
@Logger(CrisisCleanupLoggers.App) private val logger: AppLogger,
) {
fun processMainIntent(intent: Intent) {
when (val action = intent.action) {
Intent.ACTION_VIEW -> {
intent.data?.let { intentUri ->
intentUri.path?.let { urlPath ->
processMainIntent(intentUri, urlPath)
}
}
}

else -> {
logger.logDebug("Main intent action not handled $action")
}
}
}

private fun processMainIntent(url: Uri, urlPath: String) {
if (urlPath.startsWith("/o/callback")) {
url.getQueryParameter("code")?.let { code ->
authEventBus.onEmailLoginLink(code)
}
} else if (urlPath.startsWith("/password/reset/")) {
val code = urlPath.replace("/password/reset/", "")
if (code.isNotBlank()) {
authEventBus.onResetPassword(code)
}
}
}
}
17 changes: 8 additions & 9 deletions app/src/main/java/com/crisiscleanup/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,11 @@ 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.sync.SyncPuller
import com.crisiscleanup.core.data.repository.AppMetricsRepository
import com.crisiscleanup.core.data.repository.EndOfLifeRepository
import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme
import com.crisiscleanup.core.designsystem.theme.navigationContainerColor
import com.crisiscleanup.core.model.data.DarkThemeConfig
import com.crisiscleanup.core.testerfeedbackapi.FeedbackTriggerProvider
import com.crisiscleanup.core.testerfeedbackapi.di.FeedbackTriggerProviderKey
import com.crisiscleanup.core.testerfeedbackapi.di.FeedbackTriggerProviders
import com.crisiscleanup.ui.CrisisCleanupApp
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.android.gms.maps.MapsInitializer
Expand Down Expand Up @@ -82,15 +80,17 @@ class MainActivity : ComponentActivity() {
internal lateinit var permissionManager: PermissionManager

@Inject
@FeedbackTriggerProviderKey(FeedbackTriggerProviders.Default)
internal lateinit var feedbackTriggerProvider: FeedbackTriggerProvider
internal lateinit var visualAlertManager: VisualAlertManager

@Inject
internal lateinit var visualAlertManager: VisualAlertManager
internal lateinit var intentProcessor: ExternalIntentProcessor

@Inject
internal lateinit var endOfLifeRepository: EndOfLifeRepository

@Inject
internal lateinit var appMetricsRepository: AppMetricsRepository

private val lifecycleObservers = mutableListOf<LifecycleObserver>()

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -100,8 +100,6 @@ class MainActivity : ComponentActivity() {

(permissionManager as? DefaultLifecycleObserver)?.let { lifecycleObservers.add(it) }

lifecycleObservers.addAll(feedbackTriggerProvider.triggers.mapNotNull { it as? LifecycleObserver })

lifecycleObservers.forEach { lifecycle.addObserver(it) }

var uiState: MainActivityUiState by mutableStateOf(Loading)
Expand Down Expand Up @@ -156,7 +154,7 @@ class MainActivity : ComponentActivity() {
}

intent?.let {
viewModel.processMainIntent(it)
intentProcessor.processMainIntent(it)
}
}

Expand All @@ -172,6 +170,7 @@ class MainActivity : ComponentActivity() {
viewModel.onAppOpen()

endOfLifeRepository.saveEndOfLifeData()
appMetricsRepository.saveAppSupportInfo()
}

override fun onPause() {
Expand Down
73 changes: 36 additions & 37 deletions app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.crisiscleanup

import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand All @@ -21,6 +19,8 @@ import com.crisiscleanup.core.commonassets.getDisasterIcon
import com.crisiscleanup.core.data.IncidentSelector
import com.crisiscleanup.core.data.repository.AccountDataRefresher
import com.crisiscleanup.core.data.repository.AccountDataRepository
import com.crisiscleanup.core.data.repository.AppDataManagementRepository
import com.crisiscleanup.core.data.repository.ClearAppDataStep.*
import com.crisiscleanup.core.data.repository.IncidentsRepository
import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository
import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository
Expand All @@ -31,11 +31,13 @@ import com.crisiscleanup.core.model.data.AppOpenInstant
import com.crisiscleanup.core.model.data.BuildEndOfLife
import com.crisiscleanup.core.model.data.EarlybirdEndOfLifeFallback
import com.crisiscleanup.core.model.data.EmptyIncident
import com.crisiscleanup.core.model.data.MinSupportedAppVersion
import com.crisiscleanup.core.model.data.UserData
import com.google.firebase.analytics.FirebaseAnalytics
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
Expand All @@ -54,19 +56,20 @@ import kotlin.time.Duration.Companion.hours
@HiltViewModel
class MainActivityViewModel @Inject constructor(
localAppPreferencesRepository: LocalAppPreferencesRepository,
private val localAppMetricsRepository: LocalAppMetricsRepository,
private val appMetricsRepository: LocalAppMetricsRepository,
accountDataRepository: AccountDataRepository,
incidentSelector: IncidentSelector,
val appHeaderUiState: AppHeaderUiState,
incidentsRepository: IncidentsRepository,
worksitesRepository: WorksitesRepository,
appDataRepository: AppDataManagementRepository,
accountDataRefresher: AccountDataRefresher,
val translator: KeyResourceTranslator,
private val syncPuller: SyncPuller,
private val appVersionProvider: AppVersionProvider,
private val appEnv: AppEnv,
firebaseAnalytics: FirebaseAnalytics,
private val authEventBus: AuthEventBus,
authEventBus: AuthEventBus,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
@Logger(CrisisCleanupLoggers.App) private val logger: AppLogger,
) : ViewModel() {
Expand All @@ -79,7 +82,7 @@ class MainActivityViewModel @Inject constructor(

val uiState = combine(
localAppPreferencesRepository.userPreferences,
localAppMetricsRepository.metrics.distinctUntilChanged(),
appMetricsRepository.metrics.distinctUntilChanged(),
::Pair,
)
.map { (preferences, metrics) ->
Expand Down Expand Up @@ -129,6 +132,7 @@ class MainActivityViewModel @Inject constructor(
incidentSelector.incidentId,
worksitesRepository.syncWorksitesFullIncidentId,
) { incidentId, syncingIncidentId -> incidentId == syncingIncidentId }

val showHeaderLoading = combine(
incidentsRepository.isLoading,
worksitesRepository.isLoading,
Expand All @@ -151,9 +155,22 @@ class MainActivityViewModel @Inject constructor(
return null
}

val supportedApp: MinSupportedAppVersion?
get() {
if (appEnv.isProduction) {
(uiState.value as? MainActivityUiState.Success)?.let {
return it.appMetrics.minSupportedAppVersion
}
}
return null
}

// TODO Build route to auth/forgot-password rather than switches through the hierarchy
val showPasswordReset = authEventBus.showResetPassword

val isSwitchingToProduction: StateFlow<Boolean>
val productionSwitchMessage: StateFlow<String>

init {
accountDataRepository.accountData
.onEach {
Expand Down Expand Up @@ -181,7 +198,18 @@ class MainActivityViewModel @Inject constructor(
syncPuller.appPullLanguage()
syncPuller.appPullStatuses()

// TODO Check for inconsistent data
val switchProductionApiManager = SwitchProductionApiManager(
appMetricsRepository,
appDataRepository,
logger,
viewModelScope,
)
uiState
.filter { it is MainActivityUiState.Success }
.onEach { switchProductionApiManager.switchToProduction() }
.launchIn(viewModelScope)
isSwitchingToProduction = switchProductionApiManager.isSwitchingToProduction
productionSwitchMessage = switchProductionApiManager.productionSwitchMessage
}

private fun sync(cancelOngoing: Boolean) {
Expand All @@ -191,40 +219,11 @@ class MainActivityViewModel @Inject constructor(
fun onAppOpen() {
initialAppOpen.get()?.let {
viewModelScope.launch {
val previousOpen = localAppMetricsRepository.metrics.first().appOpen
val previousOpen = appMetricsRepository.metrics.first().appOpen
if (Clock.System.now() - previousOpen.date > 1.hours) {
localAppMetricsRepository.setAppOpen(appVersionProvider.versionCode)
}
}
}
}

fun processMainIntent(intent: Intent) {
when (val action = intent.action) {
Intent.ACTION_VIEW -> {
intent.data?.let { intentUri ->
intentUri.path?.let { urlPath ->
processMainIntent(intentUri, urlPath)
}
appMetricsRepository.setAppOpen(appVersionProvider.versionCode)
}
}

else -> {
logger.logDebug("Main intent action not handled $action")
}
}
}

private fun processMainIntent(url: Uri, urlPath: String) {
if (urlPath.startsWith("/o/callback")) {
url.getQueryParameter("code")?.let { code ->
authEventBus.onEmailLoginLink(code)
}
} else if (urlPath.startsWith("/password/reset/")) {
val code = urlPath.replace("/password/reset/", "")
if (code.isNotBlank()) {
authEventBus.onResetPassword(code)
}
}
}
}
Expand Down
64 changes: 64 additions & 0 deletions app/src/main/java/com/crisiscleanup/SwitchProductionApiManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.crisiscleanup

import com.crisiscleanup.core.common.log.AppLogger
import com.crisiscleanup.core.data.repository.AppDataManagementRepository
import com.crisiscleanup.core.data.repository.ClearAppDataStep
import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.concurrent.atomic.AtomicBoolean

class SwitchProductionApiManager(
private val appMetricsRepository: LocalAppMetricsRepository,
private val appDataRepository: AppDataManagementRepository,
private val logger: AppLogger,
coroutineScope: CoroutineScope,
) {
private val hasRunSwitchover = AtomicBoolean()
private val productionSwitchStep = appDataRepository.clearingAppDataStep
.map {
if (appDataRepository.clearAppDataError != ClearAppDataStep.None) {
logger.logException(Exception("Switchover failed $it"))
} else {
logger.logCapture("Switchover on $it")
}
it
}
val isSwitchingToProduction = productionSwitchStep
.map { it != ClearAppDataStep.None }
.stateIn(
scope = coroutineScope,
initialValue = false,
started = SharingStarted.WhileSubscribed(),
)

private val productionSwitchMessageLookup = mapOf(
ClearAppDataStep.None to "Getting ready...",
ClearAppDataStep.StopSyncPull to "Stopping background data sync...",
ClearAppDataStep.SyncPush to "Saving changes to cloud...",
ClearAppDataStep.ClearData to "Clearing existing data...",
ClearAppDataStep.DatabaseNotCleared to "Unable to clear data",
ClearAppDataStep.FinalClear to "Finalizing",
ClearAppDataStep.Cleared to "App is ready!",
)
val productionSwitchMessage = productionSwitchStep
.map {
productionSwitchMessageLookup[it] ?: "Something is happening..."
}
.stateIn(
scope = coroutineScope,
initialValue = "",
started = SharingStarted.WhileSubscribed(),
)

suspend fun switchToProduction() {
if (!hasRunSwitchover.getAndSet(true) &&
appMetricsRepository.metrics.first().switchToProductionApiVersion < 143
) {
appDataRepository.clearAppData()
}
}
}
8 changes: 7 additions & 1 deletion app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,20 @@ fun CrisisCleanupApp(
}
}

val isSwitchingToProduction by viewModel.isSwitchingToProduction.collectAsStateWithLifecycle()
val authState by viewModel.authState.collectAsStateWithLifecycle()
if (authState is AuthState.Loading) {
if (isSwitchingToProduction) {
SwitchToProductionView()
} else if (authState is AuthState.Loading) {
// Splash screen should be showing
} else {
CompositionLocalProvider(LocalAppTranslator provides translator) {
val endOfLife = viewModel.buildEndOfLife
val minSupportedAppVersion = viewModel.supportedApp
if (endOfLife?.isEndOfLife == true) {
EndOfLifeView(endOfLife)
} else if (minSupportedAppVersion?.isUnsupported == true) {
UnsupportedBuildView(minSupportedAppVersion)
} else {
// Render content even if translations are not fully downloaded in case internet connection is not available.
// Translations without fallbacks will show until translations are downloaded.
Expand Down
Loading

0 comments on commit 578eec6

Please sign in to comment.