diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /app/ui/base/src/main/java/com | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'app/ui/base/src/main/java/com')
13 files changed, 734 insertions, 0 deletions
diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/AppLanguageManager.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/AppLanguageManager.kt new file mode 100644 index 0000000..dd306f0 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/AppLanguageManager.kt @@ -0,0 +1,98 @@ +package com.fsck.k9.ui.base + +import android.content.res.Resources +import com.fsck.k9.K9 +import com.fsck.k9.ui.base.extensions.currentLocale +import com.fsck.k9.ui.base.locale.SystemLocaleChangeListener +import com.fsck.k9.ui.base.locale.SystemLocaleManager +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus + +/** + * Manages app language changes. + * + * - Sets the default locale when the app language is changed. + * - Notifies listeners when the app language has changed. + */ +class AppLanguageManager( + private val systemLocaleManager: SystemLocaleManager, + private val coroutineScope: CoroutineScope = GlobalScope + Dispatchers.Main +) { + private var currentOverrideLocale: Locale? = null + private val _overrideLocale = MutableSharedFlow<Locale?>(replay = 1) + private val _appLocale = MutableSharedFlow<Locale>(replay = 1) + val overrideLocale: Flow<Locale?> = _overrideLocale + val appLocale: Flow<Locale> = _appLocale + + private val systemLocaleListener = SystemLocaleChangeListener { + coroutineScope.launch { + _appLocale.emit(systemLocale) + } + } + + fun init() { + setLocale(K9.k9Language) + } + + fun getOverrideLocale(): Locale? = currentOverrideLocale + + fun getAppLanguage(): String { + return K9.k9Language + } + + fun setAppLanguage(appLanguage: String) { + if (appLanguage == K9.k9Language) { + return + } + + K9.k9Language = appLanguage + + setLocale(appLanguage) + } + + fun applyOverrideLocale() { + currentOverrideLocale?.let { overrideLocale -> + Locale.setDefault(overrideLocale) + } + } + + private fun setLocale(appLanguage: String) { + val overrideLocale = getOverrideLocaleForLanguage(appLanguage) + currentOverrideLocale = overrideLocale + + val locale = overrideLocale ?: systemLocale + Locale.setDefault(locale) + + if (overrideLocale == null) { + systemLocaleManager.addListener(systemLocaleListener) + } else { + systemLocaleManager.removeListener(systemLocaleListener) + } + + coroutineScope.launch { + _overrideLocale.emit(overrideLocale) + _appLocale.emit(locale) + } + } + + private fun getOverrideLocaleForLanguage(appLanguage: String): Locale? { + return if (appLanguage.isEmpty()) { + null + } else if (appLanguage.length == 5 && appLanguage[2] == '_') { + // language is in the form: en_US + val language = appLanguage.substring(0, 2) + val country = appLanguage.substring(3) + Locale(language, country) + } else { + Locale(appLanguage) + } + } + + private val systemLocale get() = Resources.getSystem().configuration.currentLocale +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt new file mode 100644 index 0000000..94cbc70 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt @@ -0,0 +1,87 @@ +package com.fsck.k9.ui.base + +import android.content.Context +import android.os.Build +import android.os.Bundle +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.core.app.ActivityCompat +import androidx.lifecycle.asLiveData +import com.fsck.k9.controller.push.PushController +import java.util.Locale +import org.koin.android.ext.android.inject + +abstract class K9Activity(private val themeType: ThemeType) : AppCompatActivity() { + constructor() : this(ThemeType.DEFAULT) + + private val pushController: PushController by inject() + protected val themeManager: ThemeManager by inject() + private val appLanguageManager: AppLanguageManager by inject() + + private var overrideLocaleOnLaunch: Locale? = null + + override fun attachBaseContext(baseContext: Context) { + overrideLocaleOnLaunch = appLanguageManager.getOverrideLocale() + + val newBaseContext = overrideLocaleOnLaunch?.let { locale -> + LocaleContextWrapper(baseContext, locale) + } ?: baseContext + + super.attachBaseContext(newBaseContext) + } + + override fun onCreate(savedInstanceState: Bundle?) { + initializeTheme() + initializePushController() + super.onCreate(savedInstanceState) + + setLayoutDirection() + listenForAppLanguageChanges() + } + + // On Android 12+ the layout direction doesn't seem to be updated when recreating the activity. This is a problem + // when switching from an LTR to an RTL language (or the other way around) using the language picker in the app. + private fun setLayoutDirection() { + if (Build.VERSION.SDK_INT >= 31) { + window.decorView.layoutDirection = resources.configuration.layoutDirection + } + } + + private fun listenForAppLanguageChanges() { + appLanguageManager.overrideLocale.asLiveData().observe(this) { overrideLocale -> + if (overrideLocale != overrideLocaleOnLaunch) { + recreateCompat() + } + } + } + + private fun initializeTheme() { + val theme = when (themeType) { + ThemeType.DEFAULT -> themeManager.appThemeResourceId + ThemeType.DIALOG -> themeManager.translucentDialogThemeResourceId + } + setTheme(theme) + } + + private fun initializePushController() { + pushController.init() + } + + protected fun setLayout(@LayoutRes layoutResId: Int) { + setContentView(layoutResId) + val toolbar = findViewById<Toolbar>(R.id.toolbar) + ?: error("K9 layouts must provide a toolbar with id='toolbar'.") + + setSupportActionBar(toolbar) + } + + protected fun recreateCompat() { + ActivityCompat.recreate(this) + } +} + +enum class ThemeType { + DEFAULT, + DIALOG +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt new file mode 100644 index 0000000..7fc76c2 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt @@ -0,0 +1,18 @@ +package com.fsck.k9.ui.base + +import com.fsck.k9.ui.base.locale.SystemLocaleManager +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val uiBaseModule = module { + single { + ThemeManager( + context = get(), + themeProvider = get(), + generalSettingsManager = get(), + appCoroutineScope = get(named("AppCoroutineScope")) + ) + } + single { AppLanguageManager(systemLocaleManager = get()) } + single { SystemLocaleManager(context = get()) } +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/LocaleContextWrapper.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/LocaleContextWrapper.kt new file mode 100644 index 0000000..f2bee82 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/LocaleContextWrapper.kt @@ -0,0 +1,17 @@ +package com.fsck.k9.ui.base + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import com.fsck.k9.ui.base.extensions.currentLocale +import java.util.Locale + +/** + * In combination with `AppCompatActivity` this will override the locale in the configuration. + */ +internal class LocaleContextWrapper(baseContext: Context, private val locale: Locale) : ContextWrapper(baseContext) { + override fun createConfigurationContext(overrideConfiguration: Configuration): Context { + overrideConfiguration.currentLocale = locale + return super.createConfigurationContext(overrideConfiguration) + } +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt new file mode 100644 index 0000000..87d3a32 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt @@ -0,0 +1,117 @@ +package com.fsck.k9.ui.base + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatDelegate +import com.fsck.k9.preferences.AppTheme +import com.fsck.k9.preferences.GeneralSettings +import com.fsck.k9.preferences.GeneralSettingsManager +import com.fsck.k9.preferences.SubTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus + +class ThemeManager( + private val context: Context, + private val themeProvider: ThemeProvider, + private val generalSettingsManager: GeneralSettingsManager, + private val appCoroutineScope: CoroutineScope +) { + + private val generalSettings: GeneralSettings + get() = generalSettingsManager.getSettings() + + val appTheme: Theme + get() = when (generalSettings.appTheme) { + AppTheme.LIGHT -> Theme.LIGHT + AppTheme.DARK -> Theme.DARK + AppTheme.FOLLOW_SYSTEM -> if (Build.VERSION.SDK_INT < 28) Theme.LIGHT else getSystemTheme() + } + + val messageViewTheme: Theme + get() = resolveTheme(generalSettings.messageViewTheme) + + val messageComposeTheme: Theme + get() = resolveTheme(generalSettings.messageComposeTheme) + + @get:StyleRes + val appThemeResourceId: Int = themeProvider.appThemeResourceId + + @get:StyleRes + val messageViewThemeResourceId: Int + get() = getSubThemeResourceId(generalSettings.messageViewTheme) + + @get:StyleRes + val messageComposeThemeResourceId: Int + get() = getSubThemeResourceId(generalSettings.messageComposeTheme) + + @get:StyleRes + val dialogThemeResourceId: Int = themeProvider.dialogThemeResourceId + + @get:StyleRes + val translucentDialogThemeResourceId: Int = themeProvider.translucentDialogThemeResourceId + + fun init() { + generalSettingsManager.getSettingsFlow() + .map { it.appTheme } + .distinctUntilChanged() + .onEach { + updateAppTheme(it) + } + .launchIn(appCoroutineScope + Dispatchers.Main.immediate) + } + + private fun updateAppTheme(appTheme: AppTheme) { + val defaultNightMode = when (appTheme) { + AppTheme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + AppTheme.DARK -> AppCompatDelegate.MODE_NIGHT_YES + AppTheme.FOLLOW_SYSTEM -> { + if (Build.VERSION.SDK_INT < 28) { + AppCompatDelegate.MODE_NIGHT_NO + } else { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } + } + AppCompatDelegate.setDefaultNightMode(defaultNightMode) + } + + fun toggleMessageViewTheme() { + if (messageViewTheme === Theme.DARK) { + generalSettingsManager.setMessageViewTheme(SubTheme.LIGHT) + } else { + generalSettingsManager.setMessageViewTheme(SubTheme.DARK) + } + } + + private fun getSubThemeResourceId(subTheme: SubTheme): Int = when (subTheme) { + SubTheme.LIGHT -> themeProvider.appLightThemeResourceId + SubTheme.DARK -> themeProvider.appDarkThemeResourceId + SubTheme.USE_GLOBAL -> themeProvider.appThemeResourceId + } + + private fun resolveTheme(theme: SubTheme): Theme = when (theme) { + SubTheme.LIGHT -> Theme.LIGHT + SubTheme.DARK -> Theme.DARK + SubTheme.USE_GLOBAL -> appTheme + } + + private fun getSystemTheme(): Theme { + return when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> Theme.LIGHT + Configuration.UI_MODE_NIGHT_YES -> Theme.DARK + else -> Theme.LIGHT + } + } +} + +enum class Theme { + LIGHT, + DARK +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeProvider.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeProvider.kt new file mode 100644 index 0000000..ebbe3a7 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeProvider.kt @@ -0,0 +1,20 @@ +package com.fsck.k9.ui.base + +import androidx.annotation.StyleRes + +interface ThemeProvider { + @get:StyleRes + val appThemeResourceId: Int + + @get:StyleRes + val appLightThemeResourceId: Int + + @get:StyleRes + val appDarkThemeResourceId: Int + + @get:StyleRes + val dialogThemeResourceId: Int + + @get:StyleRes + val translucentDialogThemeResourceId: Int +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/ConfigurationExtensions.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/ConfigurationExtensions.kt new file mode 100644 index 0000000..3afd7e5 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/ConfigurationExtensions.kt @@ -0,0 +1,41 @@ +package com.fsck.k9.ui.base.extensions + +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import androidx.annotation.RequiresApi +import java.util.Locale + +@Suppress("DEPRECATION") +var Configuration.currentLocale: Locale + get() { + return if (Build.VERSION.SDK_INT >= 24) { + locales[0] + } else { + locale + } + } + set(value) { + if (Build.VERSION.SDK_INT >= 24) { + setLocales(createLocaleList(value, locales)) + } else { + setLocale(value) + } + } + +@RequiresApi(24) +private fun createLocaleList(topLocale: Locale, otherLocales: LocaleList): LocaleList { + if (!otherLocales.isEmpty && otherLocales[0] == topLocale) { + return otherLocales + } + + val locales = mutableListOf(topLocale) + for (index in 0 until otherLocales.size()) { + val currentLocale = otherLocales[index] + if (currentLocale != topLocale) { + locales.add(currentLocale) + } + } + + return LocaleList(*locales.toTypedArray()) +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/NavigationExtensions.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/NavigationExtensions.kt new file mode 100644 index 0000000..ec72096 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/NavigationExtensions.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.ui.base.extensions + +import androidx.annotation.IdRes +import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment + +fun FragmentActivity.findNavController(@IdRes containerIdRes: Int): NavController { + val navHostFragment = supportFragmentManager.findFragmentById(containerIdRes) as NavHostFragment + return navHostFragment.navController +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt new file mode 100644 index 0000000..a5d6577 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt @@ -0,0 +1,134 @@ +@file:JvmName("TextInputLayoutHelper") + +package com.fsck.k9.ui.base.extensions + +import android.annotation.SuppressLint +import android.text.method.PasswordTransformationMethod +import android.view.WindowManager.LayoutParams.FLAG_SECURE +import android.widget.EditText +import android.widget.Toast +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.get +import com.google.android.material.textfield.TextInputLayout + +/** + * Configures a [TextInputLayout] so the password can only be revealed after authentication. + * + * **IMPORTANT**: Only call this after the instance state has been restored! Otherwise, restoring the previous state + * after the initial state has been set will be detected as replacing the whole text. In that case showing the password + * will be allowed without authentication. + */ +fun TextInputLayout.configureAuthenticatedPasswordToggle( + activity: FragmentActivity, + title: String, + subtitle: String, + needScreenLockMessage: String +) { + val viewModel = ViewModelProvider(activity).get<AuthenticatedPasswordToggleViewModel>() + viewModel.textInputLayout = this + viewModel.activity = activity + + fun authenticateUserAndShowPassword(activity: FragmentActivity) { + val mainExecutor = ContextCompat.getMainExecutor(activity) + + val context = activity.applicationContext + val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + // The Activity might have been recreated since this callback object was created (e.g. due to an + // orientation change). So we fetch the (new) references from the ViewModel. + viewModel.isAuthenticated = true + viewModel.activity?.setSecure(true) + viewModel.textInputLayout?.editText?.showPassword() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (errorCode == BiometricPrompt.ERROR_HW_NOT_PRESENT || + errorCode == BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL || + errorCode == BiometricPrompt.ERROR_NO_BIOMETRICS + ) { + Toast.makeText(context, needScreenLockMessage, Toast.LENGTH_SHORT).show() + } else if (errString.isNotEmpty()) { + Toast.makeText(context, errString, Toast.LENGTH_SHORT).show() + } + } + } + + BiometricPrompt(activity, mainExecutor, authenticationCallback).authenticate( + BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + .setTitle(title) + .setSubtitle(subtitle) + .build() + ) + } + + val editText = this.editText ?: error("TextInputLayout.editText == null") + + editText.doOnTextChanged { text, _, before, count -> + // Check if the password field is empty or if all of the previous text was replaced + if (text != null && before > 0 && (text.isEmpty() || text.length - count == 0)) { + viewModel.isNewPassword = true + } + } + + setEndIconOnClickListener { + if (editText.isPasswordHidden) { + if (viewModel.isShowPasswordAllowed) { + activity.setSecure(true) + editText.showPassword() + } else { + authenticateUserAndShowPassword(activity) + } + } else { + viewModel.isAuthenticated = false + editText.hidePassword() + activity.setSecure(false) + } + } +} + +private val EditText.isPasswordHidden: Boolean + get() = transformationMethod is PasswordTransformationMethod + +private fun EditText.showPassword() { + transformationMethod = null +} + +private fun EditText.hidePassword() { + transformationMethod = PasswordTransformationMethod.getInstance() +} + +private fun FragmentActivity.setSecure(secure: Boolean) { + window.setFlags(if (secure) FLAG_SECURE else 0, FLAG_SECURE) +} + +@SuppressLint("StaticFieldLeak") +class AuthenticatedPasswordToggleViewModel : ViewModel() { + val isShowPasswordAllowed: Boolean + get() = isAuthenticated || isNewPassword + + var isNewPassword = false + var isAuthenticated = false + var textInputLayout: TextInputLayout? = null + var activity: FragmentActivity? = null + set(value) { + field = value + + value?.lifecycle?.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + textInputLayout = null + field = null + } + }) + } +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LiveDataLoader.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LiveDataLoader.kt new file mode 100644 index 0000000..b227e04 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LiveDataLoader.kt @@ -0,0 +1,56 @@ +package com.fsck.k9.ui.base.loader + +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +const val LOADING_INDICATOR_DELAY = 500L + +/** + * Load data in an I/O thread. Updates the returned [LiveData] with the current loading state. + * + * If loading takes longer than [LOADING_INDICATOR_DELAY] the [LoaderState.Loading] state will be emitted so the UI can + * display a loading indicator. We use a delay so fast loads won't flash a loading indicator. + * If an exception is thrown during loading the [LoaderState.Error] state is emitted. + * If the data was loaded successfully [LoaderState.Data] will be emitted containing the data. + */ +fun <T> liveDataLoader(block: CoroutineScope.() -> T): LiveData<LoaderState<T>> = liveData { + coroutineScope { + val job = launch { + delay(LOADING_INDICATOR_DELAY) + + // Emit loading state if loading took longer than configured delay. If the data was loaded faster than that, + // this coroutine will have been canceled before the next line is executed. + emit(LoaderState.Loading) + } + + val finalState = try { + val data = withContext(Dispatchers.IO) { + block() + } + + LoaderState.Data(data) + } catch (e: Exception) { + Timber.e(e, "Error loading data") + LoaderState.Error + } + + // Cancel job that emits Loading state + job.cancelAndJoin() + + emit(finalState) + } +} + +sealed class LoaderState<out T> { + object Loading : LoaderState<Nothing>() + object Error : LoaderState<Nothing>() + class Data<T>(val data: T) : LoaderState<T>() +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LoaderStateObserver.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LoaderStateObserver.kt new file mode 100644 index 0000000..e33f777 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LoaderStateObserver.kt @@ -0,0 +1,50 @@ +package com.fsck.k9.ui.base.loader + +import android.view.View +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer + +/** + * Use to observe the [LiveData] returned by [liveDataLoader]. + * + * Works with separate views for the [LoaderState.Loading], [LoaderState.Error], and [LoaderState.Data] states. The + * view associated with the current state is made visible and the others are hidden. For the [LoaderState.Data] state + * the [displayData] function is also called. + */ +fun <T> LiveData<LoaderState<T>>.observeLoading( + owner: LifecycleOwner, + loadingView: View, + errorView: View, + dataView: View, + displayData: (T) -> Unit +) { + observe(owner, LoaderStateObserver(loadingView, errorView, dataView, displayData)) +} + +private class LoaderStateObserver<T>( + private val loadingView: View, + private val errorView: View, + private val dataView: View, + private val displayData: (T) -> Unit +) : Observer<LoaderState<T>> { + private val allViews = setOf(loadingView, errorView, dataView) + + override fun onChanged(value: LoaderState<T>) { + when (value) { + is LoaderState.Loading -> loadingView.show() + is LoaderState.Error -> errorView.show() + is LoaderState.Data -> { + dataView.show() + displayData(value.data) + } + } + } + + private fun View.show() { + for (view in allViews) { + view.isVisible = view === this + } + } +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/LocaleBroadcastReceiver.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/LocaleBroadcastReceiver.kt new file mode 100644 index 0000000..cbcb78b --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/LocaleBroadcastReceiver.kt @@ -0,0 +1,17 @@ +package com.fsck.k9.ui.base.locale + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class LocaleBroadcastReceiver : BroadcastReceiver(), KoinComponent { + private val systemLocaleManager: SystemLocaleManager by inject() + + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action == Intent.ACTION_LOCALE_CHANGED) { + systemLocaleManager.notifyListeners() + } + } +} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/SystemLocaleManager.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/SystemLocaleManager.kt new file mode 100644 index 0000000..f3d1674 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/SystemLocaleManager.kt @@ -0,0 +1,68 @@ +package com.fsck.k9.ui.base.locale + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import java.util.concurrent.CopyOnWriteArraySet +import timber.log.Timber + +class SystemLocaleManager(context: Context) { + private val packageManager = context.packageManager + private val componentName = ComponentName(context, LocaleBroadcastReceiver::class.java) + + private val listeners = CopyOnWriteArraySet<SystemLocaleChangeListener>() + + @Synchronized + fun addListener(listener: SystemLocaleChangeListener) { + if (listeners.isEmpty()) { + enableReceiver() + } + + listeners.add(listener) + } + + @Synchronized + fun removeListener(listener: SystemLocaleChangeListener) { + listeners.remove(listener) + + if (listeners.isEmpty()) { + disableReceiver() + } + } + + internal fun notifyListeners() { + for (listener in listeners) { + listener.onSystemLocaleChanged() + } + } + + private fun enableReceiver() { + Timber.v("Enable LocaleBroadcastReceiver") + try { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + } catch (e: Exception) { + Timber.e(e, "Error enabling LocaleBroadcastReceiver") + } + } + + private fun disableReceiver() { + Timber.v("Disable LocaleBroadcastReceiver") + try { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } catch (e: Exception) { + Timber.e(e, "Error disabling LocaleBroadcastReceiver") + } + } +} + +fun interface SystemLocaleChangeListener { + fun onSystemLocaleChanged() +} |
