PM-17404: Set app delegate on theme change (#4605)

This commit is contained in:
David Perez
2025-01-22 14:02:18 -06:00
committed by GitHub
parent 2787edbf45
commit bf60f8f9e3
13 changed files with 173 additions and 69 deletions

View File

@@ -66,13 +66,14 @@ class MainActivity : AppCompatActivity() {
)
}
// Within the app the language will change dynamically and will be managed
// by the OS, but we need to ensure we properly set the language when
// upgrading from older versions that handle this differently.
// Within the app the language and theme will change dynamically and will be managed by the
// OS, but we need to ensure we properly set the values when upgrading from older versions
// that handle this differently or when the activity restarts.
settingsRepository.appLanguage.localeName?.let { localeName ->
val localeList = LocaleListCompat.forLanguageTags(localeName)
AppCompatDelegate.setApplicationLocales(localeList)
}
AppCompatDelegate.setDefaultNightMode(settingsRepository.appTheme.osValue)
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
val navController = rememberNavController()
@@ -94,6 +95,16 @@ class MainActivity : AppCompatActivity() {
)
.show()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),
)
}
is MainEvent.UpdateAppTheme -> {
AppCompatDelegate.setDefaultNightMode(event.osTheme)
}
}
}
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)

View File

@@ -108,6 +108,11 @@ class MainViewModel @Inject constructor(
.appThemeStateFlow
.onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) }
.launchIn(viewModelScope)
settingsRepository
.appLanguageStateFlow
.map { MainEvent.UpdateAppLocale(it.localeName) }
.onEach(::sendEvent)
.launchIn(viewModelScope)
settingsRepository
.isScreenCaptureAllowedStateFlow
@@ -211,6 +216,7 @@ class MainViewModel @Inject constructor(
private fun handleAppThemeUpdated(action: MainAction.Internal.ThemeUpdate) {
mutableStateFlow.update { it.copy(theme = action.theme) }
sendEvent(MainEvent.UpdateAppTheme(osTheme = action.theme.osValue))
}
private fun handleVaultUnlockStateChange() {
@@ -518,4 +524,18 @@ sealed class MainEvent {
* Show a toast with the given [message].
*/
data class ShowToast(val message: Text) : MainEvent()
/**
* Indicates that the app language has been updated.
*/
data class UpdateAppLocale(
val localeName: String?,
) : MainEvent()
/**
* Indicates that the app theme has been updated.
*/
data class UpdateAppTheme(
val osTheme: Int,
) : MainEvent()
}

View File

@@ -18,6 +18,11 @@ interface SettingsDiskSource {
*/
var appLanguage: AppLanguage?
/**
* Emits updates that track [AppLanguage].
*/
val appLanguageFlow: Flow<AppLanguage?>
/**
* Has the initial autofill dialog been shown to the user.
*/

View File

@@ -10,7 +10,6 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.Instant
@@ -50,6 +49,7 @@ class SettingsDiskSourceImpl(
private val json: Json,
) : BaseDiskSource(sharedPreferences = sharedPreferences),
SettingsDiskSource {
private val mutableAppLanguageFlow = bufferedMutableSharedFlow<AppLanguage?>(replay = 1)
private val mutableAppThemeFlow = bufferedMutableSharedFlow<AppTheme>(replay = 1)
private val mutableLastSyncFlowMap = mutableMapOf<String, MutableSharedFlow<Instant?>>()
@@ -94,8 +94,12 @@ class SettingsDiskSourceImpl(
key = APP_LANGUAGE_KEY,
value = value?.localeName,
)
mutableAppLanguageFlow.tryEmit(value)
}
override val appLanguageFlow: Flow<AppLanguage?>
get() = mutableAppLanguageFlow.onSubscription { emit(appLanguage) }
override var initialAutofillDialogShown: Boolean?
get() = getBoolean(key = INITIAL_AUTOFILL_DIALOG_SHOWN)
set(value) {

View File

@@ -23,6 +23,11 @@ interface SettingsRepository {
*/
var appLanguage: AppLanguage
/**
* Tracks changes to the [AppLanguage].
*/
val appLanguageStateFlow: StateFlow<AppLanguage>
/**
* The currently stored [AppTheme].
*/

View File

@@ -64,6 +64,16 @@ class SettingsRepositoryImpl(
settingsDiskSource.appLanguage = value
}
override val appLanguageStateFlow: StateFlow<AppLanguage>
get() = settingsDiskSource
.appLanguageFlow
.map { it ?: AppLanguage.DEFAULT }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = appLanguage,
)
override var appTheme: AppTheme by settingsDiskSource::appTheme
override val appThemeStateFlow: StateFlow<AppTheme>

View File

@@ -1,8 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import android.os.Parcelable
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@@ -44,11 +42,6 @@ class AppearanceViewModel @Inject constructor(
private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
mutableStateFlow.update { it.copy(language = action.language) }
settingsRepository.appLanguage = action.language
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(
action.language.localeName,
)
AppCompatDelegate.setApplicationLocales(appLocale)
}
private fun handleShowWebsiteIconsToggled(action: AppearanceAction.ShowWebsiteIconsToggle) {

View File

@@ -1,12 +1,14 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model
import androidx.appcompat.app.AppCompatDelegate
/**
* Represents the theme options the user can set.
*
* The [value] is used for consistent storage purposes.
*/
enum class AppTheme(val value: String?) {
DEFAULT(value = null),
DARK(value = "dark"),
LIGHT(value = "light"),
enum class AppTheme(val value: String?, val osValue: Int) {
DEFAULT(value = null, osValue = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM),
DARK(value = "dark", osValue = AppCompatDelegate.MODE_NIGHT_YES),
LIGHT(value = "light", osValue = AppCompatDelegate.MODE_NIGHT_NO),
}

View File

@@ -50,6 +50,7 @@ import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -85,10 +86,12 @@ class MainViewModelTest : BaseViewModelTest() {
private val addTotpItemAuthenticatorManager = AddTotpItemFromAuthenticatorManagerImpl()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableAppLanguageFlow = MutableStateFlow(AppLanguage.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT
every { appThemeStateFlow } returns mutableAppThemeFlow
every { appLanguageStateFlow } returns mutableAppLanguageFlow
every { isScreenCaptureAllowed } returns true
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
every { storeUserHasLoggedInValue(any()) } just runs
@@ -190,6 +193,10 @@ class MainViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
mutableUserStateFlow.value = UserState(
activeUserId = userId1,
accounts = listOf(
@@ -237,6 +244,10 @@ class MainViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
mutableVaultStateEventFlow.tryEmit(VaultStateEvent.Unlocked(userId = "userId"))
expectNoEvents()
@@ -254,6 +265,10 @@ class MainViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
val cipherView = mockk<CipherView>()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
accessibilitySelectionManager.emitAccessibilitySelection(cipherView = cipherView)
assertEquals(
MainEvent.CompleteAccessibilityAutofill(cipherView = cipherView),
@@ -267,6 +282,10 @@ class MainViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
val cipherView = mockk<CipherView>()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
assertEquals(
MainEvent.CompleteAutofill(cipherView = cipherView),
@@ -291,28 +310,44 @@ class MainViewModelTest : BaseViewModelTest() {
}
@Test
fun `on AppThemeChanged should update state`() {
fun `on AppThemeChanged should update state and send event`() = runTest {
val theme = AppTheme.DARK
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
viewModel.trySendAction(
MainAction.Internal.ThemeUpdate(
theme = AppTheme.DARK,
),
)
assertEquals(
DEFAULT_STATE.copy(
theme = AppTheme.DARK,
),
viewModel.stateFlow.value,
)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// We skip the first 2 events because they are the default appTheme and appLanguage
eventFlow.awaitItem()
eventFlow.awaitItem()
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
mutableAppThemeFlow.value = theme
assertEquals(DEFAULT_STATE.copy(theme = theme), stateFlow.awaitItem())
assertEquals(MainEvent.UpdateAppTheme(osTheme = theme.osValue), eventFlow.awaitItem())
}
verify {
settingsRepository.appTheme
settingsRepository.appThemeStateFlow
settingsRepository.appLanguageStateFlow
}
}
@Test
fun `on AppLanguageChanged should send UpdateAppLocale event`() = runTest {
val language = AppLanguage.ENGLISH_BRITISH
val viewModel = createViewModel()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
mutableAppLanguageFlow.value = language
assertEquals(MainEvent.UpdateAppLocale(localeName = language.localeName), awaitItem())
}
verify(exactly = 1) {
settingsRepository.appLanguageStateFlow
}
}
@@ -511,12 +546,12 @@ class MainViewModelTest : BaseViewModelTest() {
)
} returns EmailTokenResult.Error(message = null)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = mockIntent,
),
)
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
viewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = mockIntent))
assertEquals(
MainEvent.ShowToast(R.string.there_was_an_issue_validating_the_registration_token.asText()),
awaitItem(),
@@ -548,12 +583,12 @@ class MainViewModelTest : BaseViewModelTest() {
)
} returns EmailTokenResult.Error(message = expectedMessage)
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = mockIntent,
),
)
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
viewModel.trySendAction(MainAction.ReceiveFirstIntent(intent = mockIntent))
assertEquals(
MainEvent.ShowToast(expectedMessage.asText()),
awaitItem(),
@@ -957,9 +992,12 @@ class MainViewModelTest : BaseViewModelTest() {
@Test
fun `send NavigateToDebugMenu action when OpenDebugMenu action is sent`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(MainAction.OpenDebugMenu)
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
viewModel.trySendAction(MainAction.OpenDebugMenu)
assertEquals(MainEvent.NavigateToDebugMenu, awaitItem())
}
}

View File

@@ -51,6 +51,20 @@ class SettingsDiskSourceTest {
)
}
@Test
fun `appLanguageFlow should react to changes in appLanguage`() = runTest {
val appLanguage = AppLanguage.ENGLISH_BRITISH
settingsDiskSource.appLanguageFlow.test {
// The initial values of the Flow and the property are in sync
assertNull(settingsDiskSource.appLanguage)
assertNull(awaitItem())
// Updating the repository updates shared preferences
settingsDiskSource.appLanguage = appLanguage
assertEquals(appLanguage, awaitItem())
}
}
@Test
fun `setting appLanguage should update SharedPreferences`() {
val appLanguageKey = "bwPreferencesStorage:appLocale"

View File

@@ -16,8 +16,9 @@ import java.time.Instant
*/
class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableAppThemeFlow =
bufferedMutableSharedFlow<AppTheme>(replay = 1)
private val mutableAppLanguageFlow = bufferedMutableSharedFlow<AppLanguage?>(replay = 1)
private val mutableAppThemeFlow = bufferedMutableSharedFlow<AppTheme>(replay = 1)
private val mutableLastSyncCallFlowMap = mutableMapOf<String, MutableSharedFlow<Instant?>>()
@@ -42,6 +43,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private var storedAppLanguage: AppLanguage? = null
private var storedAppTheme: AppTheme = AppTheme.DEFAULT
private val storedLastSyncTime = mutableMapOf<String, Instant?>()
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
@@ -81,7 +83,15 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableVaultRegisteredForExportFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
override var appLanguage: AppLanguage? = null
override var appLanguage: AppLanguage?
get() = storedAppLanguage
set(value) {
storedAppLanguage = value
mutableAppLanguageFlow.tryEmit(value)
}
override val appLanguageFlow: Flow<AppLanguage?>
get() = mutableAppLanguageFlow.onSubscription { emit(appLanguage) }
override var appTheme: AppTheme
get() = storedAppTheme

View File

@@ -263,6 +263,15 @@ class SettingsRepositoryTest {
)
}
@Test
fun `appLanguageStateFlow should react to changes in SettingsDiskSource`() = runTest {
settingsRepository.appLanguageStateFlow.test {
assertEquals(AppLanguage.DEFAULT, awaitItem())
fakeSettingsDiskSource.appLanguage = AppLanguage.DUTCH
assertEquals(AppLanguage.DUTCH, awaitItem())
}
}
@Test
fun `vaultLastSync should pull from and update SettingsDiskSource`() {
fakeAuthDiskSource.userState = MOCK_USER_STATE

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -10,14 +9,10 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AppearanceViewModelTest : BaseViewModelTest() {
@@ -30,16 +25,6 @@ class AppearanceViewModelTest : BaseViewModelTest() {
every { appTheme = AppTheme.DARK } just runs
}
@BeforeEach
fun setup() {
mockkStatic(AppCompatDelegate::setApplicationLocales)
}
@AfterEach
fun teardown() {
unmockkStatic(AppCompatDelegate::setApplicationLocales)
}
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
@@ -72,9 +57,7 @@ class AppearanceViewModelTest : BaseViewModelTest() {
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(
AppearanceAction.LanguageChange(AppLanguage.ENGLISH),
)
viewModel.trySendAction(AppearanceAction.LanguageChange(AppLanguage.ENGLISH))
assertEquals(
DEFAULT_STATE.copy(
language = AppLanguage.ENGLISH,
@@ -82,8 +65,8 @@ class AppearanceViewModelTest : BaseViewModelTest() {
awaitItem(),
)
}
verify {
AppCompatDelegate.setApplicationLocales(any())
mockSettingsRepository.appLanguage
mockSettingsRepository.appLanguage = AppLanguage.ENGLISH
}
@@ -126,11 +109,11 @@ class AppearanceViewModelTest : BaseViewModelTest() {
DEFAULT_STATE.copy(theme = AppTheme.DARK),
awaitItem(),
)
}
verify {
mockSettingsRepository.appTheme
mockSettingsRepository.appTheme = AppTheme.DARK
}
verify(exactly = 1) {
mockSettingsRepository.appTheme
mockSettingsRepository.appTheme = AppTheme.DARK
}
}