Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

View file

@ -0,0 +1,23 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
dependencies {
implementation(projects.app.core)
api(libs.androidx.appcompat)
api(libs.androidx.activity)
api(libs.android.material)
api(libs.androidx.navigation.fragment)
api(libs.androidx.navigation.ui)
api(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.biometric)
implementation(libs.timber)
implementation(libs.kotlinx.coroutines.core)
}
android {
namespace = "com.fsck.k9.ui.base"
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!--
This component is disabled by default. It will be enabled programmatically by SystemLocaleManager if necessary.
-->
<receiver
android:name=".locale.LocaleBroadcastReceiver"
android:exported="false"
android:enabled="false">
<intent-filter>
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -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
}

View file

@ -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
}

View file

@ -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()) }
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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
}
})
}
}

View file

@ -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>()
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.appbar.MaterialToolbar xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/toolbar"
style="?attr/toolbarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />