Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
23
app/ui/base/build.gradle.kts
Normal file
23
app/ui/base/build.gradle.kts
Normal 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"
|
||||
}
|
||||
20
app/ui/base/src/main/AndroidManifest.xml
Normal file
20
app/ui/base/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
87
app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt
Normal file
87
app/ui/base/src/main/java/com/fsck/k9/ui/base/K9Activity.kt
Normal 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
|
||||
}
|
||||
18
app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt
Normal file
18
app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt
Normal 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()) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
117
app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt
Normal file
117
app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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>()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
6
app/ui/base/src/main/res/layout/toolbar.xml
Normal file
6
app/ui/base/src/main/res/layout/toolbar.xml
Normal 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" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue