summaryrefslogtreecommitdiff
path: root/app/ui/base/src/main/java/com
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /app/ui/base/src/main/java/com
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'app/ui/base/src/main/java/com')
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/AppLanguageManager.kt98
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt87
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt18
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/LocaleContextWrapper.kt17
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt117
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeProvider.kt20
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/ConfigurationExtensions.kt41
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/NavigationExtensions.kt11
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt134
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LiveDataLoader.kt56
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/loader/LoaderStateObserver.kt50
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/LocaleBroadcastReceiver.kt17
-rw-r--r--app/ui/base/src/main/java/com/fsck/k9/ui/base/locale/SystemLocaleManager.kt68
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()
+}