diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6baaf55..2eb5fe9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") androidTestImplementation(composeBom) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") @@ -120,6 +121,9 @@ dependencies { implementation("androidx.room:room-ktx:$roomVersion") ksp("androidx.room:room-compiler:$roomVersion") + implementation("androidx.biometric:biometric:1.1.0") + implementation("androidx.security:security-crypto-ktx:1.1.0-alpha06") + implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("dev.olshevski.navigation:reimagined:1.5.0-beta01") diff --git a/app/src/main/java/com/xinto/mauth/core/auth/AuthManager.kt b/app/src/main/java/com/xinto/mauth/core/auth/AuthManager.kt new file mode 100644 index 0000000..4b58362 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/core/auth/AuthManager.kt @@ -0,0 +1,13 @@ +package com.xinto.mauth.core.auth + +import kotlinx.coroutines.flow.Flow + +interface AuthManager { + + fun getCode(): Flow + + fun setCode(code: String) + + fun removeCode() + +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/core/auth/DefaultAuthManager.kt b/app/src/main/java/com/xinto/mauth/core/auth/DefaultAuthManager.kt new file mode 100644 index 0000000..397b237 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/core/auth/DefaultAuthManager.kt @@ -0,0 +1,54 @@ +package com.xinto.mauth.core.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +class DefaultAuthManager( + context: Context +) : AuthManager { + + private val prefs = EncryptedSharedPreferences.create( + context, + "auth", + MasterKey(context = context), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + override fun getCode(): Flow { + return callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + if (key == KEY_CODE) { + trySend(sharedPreferences.getString(KEY_CODE, null)) + } + } + send(prefs.getString(KEY_CODE, null)) + prefs.registerOnSharedPreferenceChangeListener(listener) + awaitClose { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + } + } + + override fun setCode(code: String) { + prefs.edit { + putString(KEY_CODE, code) + } + } + + override fun removeCode() { + prefs.edit { + remove(KEY_CODE) + } + } + + private companion object { + const val KEY_CODE = "code" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/core/settings/DefaultSettings.kt b/app/src/main/java/com/xinto/mauth/core/settings/DefaultSettings.kt index 7788f4f..f12c6aa 100644 --- a/app/src/main/java/com/xinto/mauth/core/settings/DefaultSettings.kt +++ b/app/src/main/java/com/xinto/mauth/core/settings/DefaultSettings.kt @@ -28,6 +28,12 @@ class DefaultSettings(context: Context) : Settings { } } + override fun getUseBiometrics(): Flow { + return preferences.data.map { + it[KEY_USE_BIOMETRICS] ?: false + } + } + override suspend fun setSecureMode(value: Boolean) { preferences.edit { it[KEY_SECURE_MODE] = value @@ -40,9 +46,16 @@ class DefaultSettings(context: Context) : Settings { } } + override suspend fun setUseBiometrics(value: Boolean) { + preferences.edit { + it[KEY_USE_BIOMETRICS] = value + } + } + private companion object { val KEY_SECURE_MODE = booleanPreferencesKey("private_mode") val KEY_SORT_MODE = stringPreferencesKey("sort_mode") + val KEY_USE_BIOMETRICS = booleanPreferencesKey("use_biometrics") } } \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/core/settings/Settings.kt b/app/src/main/java/com/xinto/mauth/core/settings/Settings.kt index 7cfc96c..3740a10 100644 --- a/app/src/main/java/com/xinto/mauth/core/settings/Settings.kt +++ b/app/src/main/java/com/xinto/mauth/core/settings/Settings.kt @@ -6,7 +6,9 @@ import kotlinx.coroutines.flow.Flow interface Settings { fun getSecureMode(): Flow fun getSortMode(): Flow + fun getUseBiometrics(): Flow suspend fun setSecureMode(value: Boolean) suspend fun setSortMode(value: SortSetting) + suspend fun setUseBiometrics(value: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/di/MauthDI.kt b/app/src/main/java/com/xinto/mauth/di/MauthDI.kt index 05476c9..2558e4b 100644 --- a/app/src/main/java/com/xinto/mauth/di/MauthDI.kt +++ b/app/src/main/java/com/xinto/mauth/di/MauthDI.kt @@ -1,6 +1,8 @@ package com.xinto.mauth.di import androidx.room.Room +import com.xinto.mauth.core.auth.AuthManager +import com.xinto.mauth.core.auth.DefaultAuthManager import com.xinto.mauth.core.otp.generator.DefaultOtpGenerator import com.xinto.mauth.core.otp.generator.OtpGenerator import com.xinto.mauth.core.otp.parser.DefaultOtpUriParser @@ -14,8 +16,12 @@ import com.xinto.mauth.domain.QrRepository import com.xinto.mauth.domain.SettingsRepository import com.xinto.mauth.core.settings.DefaultSettings import com.xinto.mauth.core.settings.Settings +import com.xinto.mauth.domain.AuthRepository import com.xinto.mauth.ui.screen.account.AccountViewModel +import com.xinto.mauth.ui.screen.auth.AuthViewModel import com.xinto.mauth.ui.screen.home.HomeViewModel +import com.xinto.mauth.ui.screen.pinremove.PinRemoveViewModel +import com.xinto.mauth.ui.screen.pinsetup.PinSetupViewModel import com.xinto.mauth.ui.screen.qrscan.QrScanViewModel import com.xinto.mauth.ui.screen.settings.SettingsViewModel import org.apache.commons.codec.binary.Base32 @@ -34,6 +40,7 @@ object MauthDI { DefaultKeyTransformer(Base32()) } bind KeyTransformer::class singleOf(::DefaultSettings) bind Settings::class + singleOf(::DefaultAuthManager) bind AuthManager::class } val DbModule = module { @@ -60,13 +67,17 @@ object MauthDI { singleOf(::OtpRepository) singleOf(::QrRepository) singleOf(::SettingsRepository) + singleOf(::AuthRepository) } val UiModule = module { viewModelOf(::AccountViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::QrScanViewModel) + viewModelOf(::PinSetupViewModel) + viewModelOf(::PinRemoveViewModel) viewModelOf(::HomeViewModel) + viewModelOf(::AuthViewModel) } val all = listOf(CoreModule, DbModule, DomainModule, UiModule) diff --git a/app/src/main/java/com/xinto/mauth/domain/AuthRepository.kt b/app/src/main/java/com/xinto/mauth/domain/AuthRepository.kt new file mode 100644 index 0000000..1fb435c --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/domain/AuthRepository.kt @@ -0,0 +1,34 @@ +package com.xinto.mauth.domain + +import com.xinto.mauth.core.auth.AuthManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.map + +class AuthRepository( + private val authManager: AuthManager +) { + + private val liveCode = authManager.getCode() + + fun observeIsProtected(): Flow { + return liveCode.map { it != null } + } + + suspend fun isProtected(): Boolean { + return liveCode.first() != null + } + + suspend fun validate(code: String): Boolean { + return liveCode.first() == code + } + + fun updateCode(code: String) { + authManager.setCode(code) + } + + fun removeCode() { + authManager.removeCode() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/MainActivity.kt b/app/src/main/java/com/xinto/mauth/ui/MainActivity.kt index 15cd256..b802770 100644 --- a/app/src/main/java/com/xinto/mauth/ui/MainActivity.kt +++ b/app/src/main/java/com/xinto/mauth/ui/MainActivity.kt @@ -17,16 +17,21 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import com.xinto.mauth.domain.AuthRepository import com.xinto.mauth.domain.OtpRepository import com.xinto.mauth.domain.SettingsRepository import com.xinto.mauth.domain.model.DomainAccountInfo import com.xinto.mauth.ui.navigation.MauthDestination import com.xinto.mauth.ui.screen.account.AddAccountScreen import com.xinto.mauth.ui.screen.account.EditAccountScreen +import com.xinto.mauth.ui.screen.auth.AuthScreen import com.xinto.mauth.ui.screen.home.HomeScreen +import com.xinto.mauth.ui.screen.pinremove.PinRemoveScreen +import com.xinto.mauth.ui.screen.pinsetup.PinSetupScreen import com.xinto.mauth.ui.screen.qrscan.QrScanScreen import com.xinto.mauth.ui.screen.settings.SettingsScreen import com.xinto.mauth.ui.theme.MauthTheme @@ -37,12 +42,14 @@ import dev.olshevski.navigation.reimagined.rememberNavController import dev.olshevski.navigation.reimagined.replaceAll import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking import org.koin.android.ext.android.inject -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { private val settings: SettingsRepository by inject() private val otp: OtpRepository by inject() + private val auth: AuthRepository by inject() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -63,13 +70,21 @@ class MainActivity : ComponentActivity() { } .launchIn(lifecycleScope) + val initialScreen = runBlocking { + if (auth.isProtected()) { + MauthDestination.Auth + } else { + MauthDestination.Home + } + } + setContent { MauthTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - val navigator = rememberNavController(MauthDestination.Home) + val navigator = rememberNavController(initialScreen) LaunchedEffect(intent.data) { val accountInfo = otp.parseUriToAccountInfo(intent.data.toString()) @@ -104,6 +119,13 @@ class MainActivity : ComponentActivity() { } ) { screen -> when (screen) { + is MauthDestination.Auth -> { + AuthScreen( + onAuthSuccess = { + navigator.replaceAll(MauthDestination.Home) + } + ) + } is MauthDestination.Home -> { HomeScreen( onAddAccountManually = { @@ -138,6 +160,12 @@ class MainActivity : ComponentActivity() { SettingsScreen( onBack = { navigator.pop() + }, + onSetupPinCode = { + navigator.navigate(MauthDestination.PinSetup) + }, + onDisablePinCode = { + navigator.navigate(MauthDestination.PinRemove) } ) } @@ -157,6 +185,20 @@ class MainActivity : ComponentActivity() { } ) } + is MauthDestination.PinSetup -> { + PinSetupScreen( + onExit = { + navigator.pop() + } + ) + } + is MauthDestination.PinRemove -> { + PinRemoveScreen( + onExit = { + navigator.pop() + } + ) + } } } } diff --git a/app/src/main/java/com/xinto/mauth/ui/component/Biometric.kt b/app/src/main/java/com/xinto/mauth/ui/component/Biometric.kt new file mode 100644 index 0000000..c78b002 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/component/Biometric.kt @@ -0,0 +1,99 @@ +package com.xinto.mauth.ui.component + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity + +@Composable +fun rememberBiometricHandler( + onAuthSuccess: () -> Unit = {}, + onAuthFailed: () -> Unit = {}, + onAuthError: (errorCode: Int, errorString: String) -> Unit = {_, _ -> } +): BiometricHandler { + val context = LocalContext.current + return remember(context, onAuthSuccess, onAuthFailed, onAuthError) { + BiometricHandler( + context = context, + onAuthSuccess = onAuthSuccess, + onAuthFailed = onAuthFailed, + onAuthError = onAuthError + ) + } +} + +@Immutable +class BiometricHandler( + context: Context, + onAuthSuccess: () -> Unit, + onAuthFailed: () -> Unit, + onAuthError: (errorCode: Int, errorString: String) -> Unit, +) { + + private val biometricManager = BiometricManager.from(context) + private val biometricPrompt = BiometricPrompt( + context as FragmentActivity, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) = onAuthSuccess() + + override fun onAuthenticationFailed() = onAuthFailed() + + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) = onAuthError(errorCode, errString.toString()) + } + ) + + fun canUseBiometrics(): Boolean { + return biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG + ) == BiometricManager.BIOMETRIC_SUCCESS + } + + fun requestBiometrics(promptData: BiometricPromptData) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(promptData.title) + .setSubtitle(promptData.subtitle) + .setDescription(promptData.description) + .setNegativeButtonText(promptData.negativeButtonText) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + biometricPrompt.authenticate(promptInfo) + } + + fun cancelRequest() { + biometricPrompt.cancelAuthentication() + } +} + +@Composable +fun rememberBiometricPromptData( + title: String, + negativeButtonText: String, + subtitle: String? = null, + description: String? = null +) : BiometricPromptData { + return remember(title, negativeButtonText, subtitle, description) { + BiometricPromptData( + title = title, + negativeButtonText = negativeButtonText, + subtitle = subtitle, + description = description + ) + } +} + +@Immutable +data class BiometricPromptData( + val title: String, + val negativeButtonText: String, + val subtitle: String? = null, + val description: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/component/Shapes.kt b/app/src/main/java/com/xinto/mauth/ui/component/Shapes.kt index b08a2ca..a821d2f 100644 --- a/app/src/main/java/com/xinto/mauth/ui/component/Shapes.kt +++ b/app/src/main/java/com/xinto/mauth/ui/component/Shapes.kt @@ -1,39 +1,88 @@ package com.xinto.mauth.ui.component +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Transition import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.animateValue import androidx.compose.animation.core.animateValueAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Size import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +fun getShapeConverter(size: Size, density: Density): TwoWayConverter { + return TwoWayConverter( + convertToVector = { + AnimationVector( + v1 = it.topStart.toPx(size, density), + v2 = it.topEnd.toPx(size, density), + v3 = it.bottomStart.toPx(size, density), + v4 = it.bottomEnd.toPx(size, density) + ) + }, + convertFromVector = { + RoundedCornerShape( + topStart = it.v1, + topEnd = it.v2, + bottomStart = it.v3, + bottomEnd = it.v4 + ) + } + ) +} + +val RoundedCornerShapeVisibilityThreshold = RoundedCornerShape(0.5f) @Composable fun animateRoundedCornerShapeAsState( - targetValue: CornerBasedShape + targetValue: CornerBasedShape, + size: Size = Size.Unspecified, + visibilityThreshold: CornerBasedShape = RoundedCornerShapeVisibilityThreshold, + animationSpec: AnimationSpec = remember { spring() }, + label: String = "ShapeAnimation", + finishedListener: ((CornerBasedShape) -> Unit)? = null ): State { - val density = LocalDensity.current return animateValueAsState( targetValue = targetValue, - typeConverter = TwoWayConverter( - convertToVector = { - AnimationVector( - v1 = it.topStart.toPx(Size.Unspecified, density), - v2 = it.topEnd.toPx(Size.Unspecified, density), - v3 = it.bottomStart.toPx(Size.Unspecified, density), - v4 = it.bottomEnd.toPx(Size.Unspecified, density) - ) - }, - convertFromVector = { - RoundedCornerShape( - topStart = it.v1, - topEnd = it.v2, - bottomStart = it.v3, - bottomEnd = it.v4 - ) - } - ) + typeConverter = getShapeConverter(size, LocalDensity.current), + animationSpec = animationSpec, + visibilityThreshold = visibilityThreshold, + label = label, + finishedListener = finishedListener + ) +} + +@Composable +inline fun Transition.animateRoundedCornerShape( + size: Size = Size.Unspecified, + noinline transitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { + spring(visibilityThreshold = RoundedCornerShapeVisibilityThreshold) + }, + label: String = "OffsetAnimation", + targetValueByState: @Composable (state: S) -> CornerBasedShape +): State { + return animateValue( + typeConverter = getShapeConverter(size, LocalDensity.current), + transitionSpec = transitionSpec, + label = label, + targetValueByState = targetValueByState ) -} \ No newline at end of file +} + +fun Animatable( + initialValue: CornerBasedShape, + density: Density, + size: Size +): Animatable = Animatable( + initialValue = initialValue, + typeConverter = getShapeConverter(size, density) +) \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinBoard.kt b/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinBoard.kt new file mode 100644 index 0000000..4948a34 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinBoard.kt @@ -0,0 +1,218 @@ +package com.xinto.mauth.ui.component.pinboard + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.xinto.mauth.R +import com.xinto.mauth.ui.theme.MauthTheme + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PinBoard( + modifier: Modifier = Modifier, + state: PinBoardState = rememberPinBoardState() +) { + FlowRow( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + maxItemsInEachRow = 3 + ) { + state.buttons.forEach { button -> + when (button) { + is PinBoardState.PinBoardButton.Number -> { + PinButton( + modifier = Modifier.weight(1f), + onClick = { state.onNumberClick(button.number) } + ) { + Text(button.toString()) + } + } + is PinBoardState.PinBoardButton.Backspace, + is PinBoardState.PinBoardButton.Fingerprint, + is PinBoardState.PinBoardButton.Enter -> { + PrimaryPinButton( + modifier = Modifier.weight(1f), + onClick = when (button) { + is PinBoardState.PinBoardButton.Backspace -> state.onBackspaceClick + is PinBoardState.PinBoardButton.Fingerprint -> state.onFingerprintClick + is PinBoardState.PinBoardButton.Enter -> state.onEnterClick + else -> throw NoSuchElementException() + }, + onLongClick = + if (button is PinBoardState.PinBoardButton.Backspace) + state.onBackspaceLongClick + else null + ) { + Icon( + modifier = Modifier.fillMaxSize(0.4f).aspectRatio(1f), + painter = painterResource( + id = when (button) { + is PinBoardState.PinBoardButton.Backspace -> R.drawable.ic_backspace + is PinBoardState.PinBoardButton.Fingerprint -> R.drawable.ic_fingerprint + is PinBoardState.PinBoardButton.Enter -> R.drawable.ic_tab + else -> throw NoSuchElementException() + } + ), + contentDescription = null + ) + } + } + is PinBoardState.PinBoardButton.Empty -> { + Spacer(Modifier.aspectRatio(1f).weight(1f).size(PinButtonDefaults.PinButtonMinSize)) + } + } + } + } +} + +@Composable +fun rememberPinBoardState( + showFingerprint: Boolean = false, + showEnter: Boolean = false, + onNumberClick: (Char) -> Unit = {}, + onBackspaceClick: () -> Unit = {}, + onBackspaceLongClick: () -> Unit = {}, + onEnterClick: () -> Unit = {}, + onFingerprintClick: () -> Unit = {}, +): PinBoardState { + return remember( + showFingerprint, + showEnter, + onNumberClick, + onBackspaceClick, + onBackspaceLongClick, + onEnterClick, + onFingerprintClick, + ) { + PinBoardState( + showFingerprint = showFingerprint, + showEnter = showEnter, + onNumberClick = onNumberClick, + onBackspaceClick = onBackspaceClick, + onBackspaceLongClick = onBackspaceLongClick, + onEnterClick = onEnterClick, + onFingerprintClick = onFingerprintClick + ) + } +} + +@Immutable +data class PinBoardState( + val showFingerprint: Boolean, + val showEnter: Boolean, + val onNumberClick: (Char) -> Unit, + val onBackspaceClick: () -> Unit, + val onBackspaceLongClick: () -> Unit = {}, + val onEnterClick: () -> Unit, + val onFingerprintClick: () -> Unit, +) { + + val buttons = buildList { + ('1'..'9').forEach { + add(PinBoardButton.Number(it)) + } + + if (showFingerprint) { + add(PinBoardButton.Fingerprint) + } else if (showEnter) { + add(PinBoardButton.Backspace) + } else { + add(PinBoardButton.Empty) + } + + add(PinBoardButton.Number('0')) + + if (showEnter) { + add(PinBoardButton.Enter) + } else { + add(PinBoardButton.Backspace) + } + } + + sealed interface PinBoardButton { + + @JvmInline + value class Number(val number: Char) : PinBoardButton { + override fun toString() = number.toString() + } + + data object Fingerprint : PinBoardButton + data object Backspace : PinBoardButton + data object Enter : PinBoardButton + data object Empty : PinBoardButton + } +} + + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PinBoardPreview_Plain() { + MauthTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PinBoard( + state = rememberPinBoardState(), + ) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PinBoardPreview_WithFingerprint() { + MauthTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PinBoard( + state = rememberPinBoardState(showFingerprint = true), + ) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PinBoardPreview_WithEnter() { + MauthTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PinBoard( + state = rememberPinBoardState(showEnter = true), + ) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PinBoardPreview_WithFingerprintAndEnter() { + MauthTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PinBoard( + state = rememberPinBoardState( + showFingerprint = true, + showEnter = true, + ), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinButton.kt b/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinButton.kt new file mode 100644 index 0000000..9e3b37f --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinButton.kt @@ -0,0 +1,243 @@ +package com.xinto.mauth.ui.component.pinboard + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.xinto.mauth.ui.component.Animatable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch + +@Composable +fun PrimaryPinButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, + colors: PinButtonColors = PinButtonDefaults.primaryPinButtonColors(), + shapes: PinButtonShapes = PinButtonDefaults.plainPinButtonShapes(), + content: @Composable () -> Unit +) = PinButton( + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + enabled = enabled, + colors = colors, + shapes = shapes, + content = content +) +@Composable +fun PinButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, + colors: PinButtonColors = PinButtonDefaults.plainPinButtonColors(), + shapes: PinButtonShapes = PinButtonDefaults.plainPinButtonShapes(), + content: @Composable () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val shape by shapes.getButtonShape(interactionSource) + val backgroundColor by colors.getBackgroundColor(interactionSource) + val contentColor by colors.getForegroundColor(interactionSource) + Box( + modifier = modifier + .aspectRatio(1f) + .sizeIn( + minWidth = PinButtonDefaults.PinButtonMinSize, + minHeight = PinButtonDefaults.PinButtonMinSize, + ) + .graphicsLayer { + clip = true + this.shape = shape + } + .drawBehind { + drawRect(backgroundColor) + } + .combinedClickable( + onClick = onClick, + enabled = enabled, + indication = null, + interactionSource = interactionSource, + onLongClick = onLongClick + ), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.headlineMedium, + LocalContentColor provides contentColor, + content = content + ) + } +} + +object PinButtonDefaults { + + val PinButtonMinSize = 72.dp + const val AnimationDurationPress = 200 + const val AnimationDurationRelease = 150 + + @Composable + fun plainPinButtonColors(): PinButtonColors { + return PinButtonColors( + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + backgroundColorPressed = MaterialTheme.colorScheme.primary, + foregroundColor = MaterialTheme.colorScheme.onSurface, + foregroundColorPressed = MaterialTheme.colorScheme.onPrimary + ) + } + + @Composable + fun primaryPinButtonColors(): PinButtonColors { + return PinButtonColors( + backgroundColor = MaterialTheme.colorScheme.secondary, + backgroundColorPressed = MaterialTheme.colorScheme.primary, + foregroundColor = MaterialTheme.colorScheme.onSecondary, + foregroundColorPressed = MaterialTheme.colorScheme.onPrimary + ) + } + + @Composable + fun plainPinButtonShapes(): PinButtonShapes { + return PinButtonShapes( + shape = RoundedCornerShape(50), + shapePressed = MaterialTheme.shapes.large + ) + } + +} + +@Stable +data class PinButtonColors( + val backgroundColor: Color, + val backgroundColorPressed: Color, + val foregroundColor: Color, + val foregroundColorPressed: Color +) { + @Composable + fun getBackgroundColor(interactionSource: InteractionSource): State { + val animatable = remember { Animatable(backgroundColor) } + return animatePressValue( + animatable = animatable, + initialValue = backgroundColor, + targetValue = backgroundColorPressed, + interactionSource = interactionSource + ) + } + + @Composable + fun getForegroundColor(interactionSource: InteractionSource): State { + val animatable = remember { Animatable(foregroundColor) } + return animatePressValue( + animatable = animatable, + initialValue = foregroundColor, + targetValue = foregroundColorPressed, + interactionSource = interactionSource + ) + } +} + +@Stable +data class PinButtonShapes( + val shape: CornerBasedShape, + val shapePressed: CornerBasedShape +) { + + @Composable + fun getButtonShape(interactionSource: InteractionSource): State { + val density = LocalDensity.current + val size = with(density) { + val shapeSize = PinButtonDefaults.PinButtonMinSize.toPx() + Size(shapeSize, shapeSize) + } + + val animatable = remember(density, size) { + Animatable(shape, density, size) + } + return animatePressValue( + animatable = animatable, + initialValue = shape, + targetValue = shapePressed, + interactionSource = interactionSource + ) + } + +} + +@Composable +private fun animatePressValue( + animatable: Animatable, + initialValue: T, + targetValue: T, + interactionSource: InteractionSource +): State { + LaunchedEffect(interactionSource, initialValue, targetValue) { + val channel = Channel(1, onBufferOverflow = BufferOverflow.DROP_LATEST) { + println(it) + } + launch { + interactionSource.interactions.collect { + if (it is PressInteraction.Press) { + if (animatable.value != targetValue) { //fix animation deadlock + animatable.animateTo( + targetValue = targetValue, + animationSpec = tween(PinButtonDefaults.AnimationDurationPress), + ) + } + channel.send(Unit) + } + } + } + launch { + interactionSource.interactions.collect { + if (it is PressInteraction.Release || it is PressInteraction.Cancel) { + try { + channel.receive() + animatable.animateTo( + targetValue = initialValue, + animationSpec = tween(PinButtonDefaults.AnimationDurationRelease) + ) + } catch (e: CancellationException) { + e.printStackTrace() + } + } + } + } + } + return animatable.asState() +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinDisplay.kt b/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinDisplay.kt new file mode 100644 index 0000000..a0eb2fc --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/component/pinboard/PinDisplay.kt @@ -0,0 +1,88 @@ +package com.xinto.mauth.ui.component.pinboard + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.xinto.mauth.ui.theme.MauthTheme + +@Composable +fun PinDisplay( + length: Int, + modifier: Modifier = Modifier, + error: Boolean = false, +) { + val inspectionMode = LocalInspectionMode.current + val color = when (error) { + true -> MaterialTheme.colorScheme.errorContainer + false -> MaterialTheme.colorScheme.secondaryContainer + } + Surface( + modifier = modifier, + color = color, + shape = CircleShape + ) { + Row( + modifier = Modifier + .height(64.dp) + .padding(horizontal = 8.dp) + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0.. Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(containerColor), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + description: (@Composable () -> Unit)? = null, + error: Boolean = false, + codeLength: Int, +) { + Scaffold( + modifier = modifier, + topBar = topBar, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + containerColor = containerColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(it) + .padding(32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (description != null) { + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.headlineSmall.copy( + textAlign = TextAlign.Center + ) + ) { + description() + } + } + Spacer(modifier = Modifier.weight(1f)) + } + PinDisplay( + modifier = Modifier + .fillMaxWidth(), + length = codeLength, + error = error, + ) + PinBoard( + modifier = Modifier + .padding(horizontal = 8.dp) + .padding(top = 32.dp), + state = state + ) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +fun PinScaffold_WithDescription() { + MauthTheme { + PinScaffold( + description = { + Text("Enter PIN") + }, + codeLength = 5 + ) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +fun PinScaffold_WithoutDescription() { + MauthTheme { + PinScaffold( + codeLength = 5 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/navigation/MauthDestination.kt b/app/src/main/java/com/xinto/mauth/ui/navigation/MauthDestination.kt index b4b4898..0e44b64 100644 --- a/app/src/main/java/com/xinto/mauth/ui/navigation/MauthDestination.kt +++ b/app/src/main/java/com/xinto/mauth/ui/navigation/MauthDestination.kt @@ -6,6 +6,10 @@ import kotlinx.parcelize.Parcelize import java.util.UUID sealed class MauthDestination(val isFullscreenDialog: Boolean = false) : Parcelable { + + @Parcelize + data object Auth : MauthDestination() + @Parcelize data object Home : MauthDestination() @@ -24,4 +28,10 @@ sealed class MauthDestination(val isFullscreenDialog: Boolean = false) : Parcela @Parcelize data object Settings : MauthDestination() + + @Parcelize + data object PinSetup : MauthDestination() + + @Parcelize + data object PinRemove : MauthDestination() } \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/auth/AuthScreen.kt b/app/src/main/java/com/xinto/mauth/ui/screen/auth/AuthScreen.kt new file mode 100644 index 0000000..720c62e --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/auth/AuthScreen.kt @@ -0,0 +1,104 @@ +package com.xinto.mauth.ui.screen.auth + +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xinto.mauth.R +import com.xinto.mauth.ui.component.BiometricHandler +import com.xinto.mauth.ui.component.pinboard.PinScaffold +import com.xinto.mauth.ui.component.pinboard.rememberPinBoardState +import com.xinto.mauth.ui.component.rememberBiometricHandler +import com.xinto.mauth.ui.component.rememberBiometricPromptData +import org.koin.androidx.compose.getViewModel + +@Composable +fun AuthScreen( + modifier: Modifier = Modifier, + onAuthSuccess: () -> Unit +) { + val viewModel: AuthViewModel = getViewModel() + val code by viewModel.code.collectAsStateWithLifecycle() + val useBiometrics by viewModel.useBiometrics.collectAsStateWithLifecycle() + + val biometricHandler = rememberBiometricHandler( + onAuthSuccess = onAuthSuccess, + ) + val promptData = rememberBiometricPromptData( + title = stringResource(R.string.auth_biometrics_title), + negativeButtonText = stringResource(R.string.auth_biometrics_cancel) + ) + val canUseBiometrics by remember(biometricHandler) { + derivedStateOf { + useBiometrics && biometricHandler.canUseBiometrics() + } + } + + LaunchedEffect(code) { + if (viewModel.validate(code)) { + onAuthSuccess() + } + } + DisposableEffect(biometricHandler, canUseBiometrics) { + if (canUseBiometrics) { + biometricHandler.requestBiometrics(promptData) + } + + onDispose { + biometricHandler.cancelRequest() + } + } + AuthScreen( + modifier = modifier, + code = code, + onNumberAdd = { + if (viewModel.insertNumber(it)) { + onAuthSuccess() + } + }, + onNumberDelete = viewModel::deleteNumber, + onClear = viewModel::clear, + showFingerprint = canUseBiometrics, + onFingerprintClick = { + biometricHandler.requestBiometrics(promptData) + } + ) +} + +@Composable +fun AuthScreen( + code: String, + onNumberAdd: (Char) -> Unit, + onNumberDelete: () -> Unit, + onClear: () -> Unit, + showFingerprint: Boolean, + onFingerprintClick: () -> Unit, + modifier: Modifier = Modifier +) { + val pinBoardState = rememberPinBoardState( + showFingerprint = showFingerprint, + onFingerprintClick = onFingerprintClick, + onNumberClick = onNumberAdd, + onBackspaceClick = onNumberDelete, + onBackspaceLongClick = onClear + ) + PinScaffold( + modifier = modifier, + topBar = { + CenterAlignedTopAppBar( + title = { + Text(stringResource(R.string.app_name)) + } + ) + }, + codeLength = code.length, + state = pinBoardState + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/auth/AuthViewModel.kt b/app/src/main/java/com/xinto/mauth/ui/screen/auth/AuthViewModel.kt new file mode 100644 index 0000000..af3ddb2 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/auth/AuthViewModel.kt @@ -0,0 +1,49 @@ +package com.xinto.mauth.ui.screen.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xinto.mauth.domain.AuthRepository +import com.xinto.mauth.domain.SettingsRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking + +class AuthViewModel( + private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository +) : ViewModel() { + + private val _code = MutableStateFlow("") + val code = _code.asStateFlow() + + val useBiometrics = settingsRepository.getUseBiometrics() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false + ) + + fun insertNumber(number: Char): Boolean { + return _code.getAndUpdate { + it + number + } == "5746" + } + + fun deleteNumber() { + _code.update { it.dropLast(1) } + } + + fun clear() { + _code.value = "" + } + + fun validate(code: String): Boolean { + return runBlocking { + authRepository.validate(code) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveScreen.kt b/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveScreen.kt new file mode 100644 index 0000000..148fbba --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveScreen.kt @@ -0,0 +1,76 @@ +package com.xinto.mauth.ui.screen.pinremove + +import androidx.activity.compose.BackHandler +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xinto.mauth.R +import com.xinto.mauth.ui.component.pinboard.PinScaffold +import com.xinto.mauth.ui.component.pinboard.rememberPinBoardState +import org.koin.androidx.compose.getViewModel + +@Composable +fun PinRemoveScreen( + onExit: () -> Unit +) { + val viewModel: PinRemoveViewModel = getViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + BackHandler(onBack = onExit) + PinRemoveScreen( + state = state, + onEnter = { + if (viewModel.removePin()) { + onExit() + } + }, + onBack = onExit, + onNumberEnter = viewModel::addNumber, + onNumberDelete = viewModel::deleteLast, + onAllDelete = viewModel::clear + ) +} + +@Composable +fun PinRemoveScreen( + state: PinRemoveScreenState, + onEnter: () -> Unit, + onBack: () -> Unit, + onNumberEnter: (Char) -> Unit, + onNumberDelete: () -> Unit, + onAllDelete: () -> Unit, +) { + PinScaffold( + codeLength = state.code.length, + error = state is PinRemoveScreenState.Error, + topBar = { + LargeTopAppBar( + title = { + Text(stringResource(R.string.pinremove_title)) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = null + ) + } + } + ) + }, + description = null, + state = rememberPinBoardState( + showEnter = true, + onNumberClick = onNumberEnter, + onBackspaceClick = onNumberDelete, + onEnterClick = onEnter, + onBackspaceLongClick = onAllDelete + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveScreenState.kt b/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveScreenState.kt new file mode 100644 index 0000000..54e4c3c --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveScreenState.kt @@ -0,0 +1,17 @@ +package com.xinto.mauth.ui.screen.pinremove + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface PinRemoveScreenState { + val code: String + + @Immutable + @JvmInline + value class Stale(override val code: String) : PinRemoveScreenState + + @Immutable + data object Error : PinRemoveScreenState { + override val code: String = "" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveViewModel.kt b/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveViewModel.kt new file mode 100644 index 0000000..dba0853 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/pinremove/PinRemoveViewModel.kt @@ -0,0 +1,60 @@ +package com.xinto.mauth.ui.screen.pinremove + +import androidx.lifecycle.ViewModel +import com.xinto.mauth.domain.AuthRepository +import com.xinto.mauth.domain.SettingsRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking + +class PinRemoveViewModel( + private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository +) : ViewModel() { + + private val _state = MutableStateFlow(PinRemoveScreenState.Stale("")) + val state = _state.asStateFlow() + + /** + * @return true if the screen should exit + */ + fun removePin(): Boolean { + return state.value.let { + runBlocking { + authRepository.validate(it.code).also { valid -> + if (valid) { + authRepository.removeCode() + settingsRepository.setUseBiometrics(false) + } + } + }.also { valid -> + if (!valid) { + _state.value = PinRemoveScreenState.Error + } + } + } + } + + fun addNumber(number: Char) { + _state.update { + when (it) { + is PinRemoveScreenState.Stale -> PinRemoveScreenState.Stale(it.code + number) + is PinRemoveScreenState.Error -> PinRemoveScreenState.Stale(number.toString()) + } + } + } + + fun deleteLast() { + _state.update { + if (it is PinRemoveScreenState.Stale) { + PinRemoveScreenState.Stale(it.code.dropLast(1)) + } else it + } + } + + fun clear() { + _state.value = PinRemoveScreenState.Stale("") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupScreen.kt b/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupScreen.kt new file mode 100644 index 0000000..681a1cc --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupScreen.kt @@ -0,0 +1,102 @@ +package com.xinto.mauth.ui.screen.pinsetup + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xinto.mauth.R +import com.xinto.mauth.ui.component.pinboard.PinScaffold +import com.xinto.mauth.ui.component.pinboard.rememberPinBoardState +import org.koin.androidx.compose.getViewModel + +@Composable +fun PinSetupScreen( + onExit: () -> Unit +) { + val viewModel: PinSetupViewModel = getViewModel() + val code by viewModel.code.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + BackHandler(onBack = onExit) + PinSetupScreen( + code = code, + state = state, + error = error, + onNext = { + if (viewModel.next()) { + onExit() + } + }, + onPrevious = { + if (viewModel.previous()) { + onExit() + } + }, + onNumberEnter = viewModel::addNumber, + onNumberDelete = viewModel::deleteLast, + onAllDelete = viewModel::clear + ) +} + +@Composable +fun PinSetupScreen( + code: String, + state: PinSetupScreenState, + error: Boolean, + onNext: () -> Unit, + onPrevious: () -> Unit, + onNumberEnter: (Char) -> Unit, + onNumberDelete: () -> Unit, + onAllDelete: () -> Unit, +) { + PinScaffold( + codeLength = code.length, + error = error, + topBar = { + LargeTopAppBar( + title = { + AnimatedContent( + targetState = state, + label = "PinSetupDescription", + transitionSpec = { + fadeIn() togetherWith fadeOut() + } + ) { + val resource = when (it) { + is PinSetupScreenState.Initial -> R.string.pinsetup_title_create + is PinSetupScreenState.Confirm -> R.string.pinsetup_title_confirm + } + Text(stringResource(resource)) + } + }, + navigationIcon = { + IconButton(onClick = onPrevious) { + Icon( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = null + ) + } + } + ) + }, + description = null, + state = rememberPinBoardState( + showEnter = true, + onNumberClick = onNumberEnter, + onBackspaceClick = onNumberDelete, + onEnterClick = onNext, + onBackspaceLongClick = onAllDelete + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupScreenState.kt b/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupScreenState.kt new file mode 100644 index 0000000..d8f06d4 --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupScreenState.kt @@ -0,0 +1,12 @@ +package com.xinto.mauth.ui.screen.pinsetup + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface PinSetupScreenState { + @Immutable + data object Initial : PinSetupScreenState + + @Immutable + data object Confirm : PinSetupScreenState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupViewModel.kt b/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupViewModel.kt new file mode 100644 index 0000000..b4091ce --- /dev/null +++ b/app/src/main/java/com/xinto/mauth/ui/screen/pinsetup/PinSetupViewModel.kt @@ -0,0 +1,75 @@ +package com.xinto.mauth.ui.screen.pinsetup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xinto.mauth.domain.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PinSetupViewModel( + private val authRepository: AuthRepository +) : ViewModel() { + + private var initialCode: String? = null + + private val _error = MutableStateFlow(false) + val error = _error.asStateFlow() + + private val _code = MutableStateFlow("") + val code = _code.asStateFlow() + + private val _state = MutableStateFlow(PinSetupScreenState.Initial) + val state = _state.asStateFlow() + + /** + * @return true if the screen should exit + */ + fun next(): Boolean { + if (state.value is PinSetupScreenState.Confirm) { + val matches = initialCode == code.value + if (matches) { + authRepository.updateCode(code.value) + } else { + _error.value = true + clear() + } + return matches + } + + _state.value = PinSetupScreenState.Confirm + _code.update { + initialCode = it + "" + } + return false + } + + /** + * @return true if the screen should exit + */ + fun previous(): Boolean { + if (state.value is PinSetupScreenState.Initial) { + return true + } + + clear() + _state.value = PinSetupScreenState.Initial + return false + } + + fun addNumber(number: Char) { + _error.value = false + _code.update { it + number } + } + + fun deleteLast() { + _code.update { it.dropLast(1) } + } + + fun clear() { + _code.value = "" + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsScreen.kt index 6c2eda1..07f1a32 100644 --- a/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsScreen.kt @@ -17,19 +17,53 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.xinto.mauth.R -import com.xinto.mauth.ui.screen.settings.component.SettingsSwitch +import com.xinto.mauth.ui.component.rememberBiometricHandler +import com.xinto.mauth.ui.component.rememberBiometricPromptData +import com.xinto.mauth.ui.screen.settings.component.SettingsSwitchItem import org.koin.androidx.compose.koinViewModel @Composable fun SettingsScreen( - onBack: () -> Unit + onBack: () -> Unit, + onSetupPinCode: () -> Unit, + onDisablePinCode: () -> Unit, ) { val viewModel: SettingsViewModel = koinViewModel() val secureMode by viewModel.secureMode.collectAsStateWithLifecycle() + val pinLock by viewModel.pinLock.collectAsStateWithLifecycle() + val biometrics by viewModel.biometrics.collectAsStateWithLifecycle() + + val biometricHandler = rememberBiometricHandler( + onAuthSuccess = viewModel::toggleBiometrics + ) + val setupPromptData = rememberBiometricPromptData( + title = stringResource(R.string.settings_biometrics_setup_title), + negativeButtonText = stringResource(R.string.settings_biometrics_setup_cancel) + ) + val disablePromptData = rememberBiometricPromptData( + title = stringResource(R.string.settings_biometrics_disable_title), + negativeButtonText = stringResource(R.string.settings_biometrics_disable_cancel) + ) + + BackHandler(onBack = onBack) SettingsScreen( onBack = onBack, secureMode = secureMode, - onSecureModeChange = viewModel::updateSecureMode + onSecureModeChange = viewModel::updateSecureMode, + pinCode = pinLock, + onPinCodeChange = { + if (it) { + onSetupPinCode() + } else { + onDisablePinCode() + } + }, + showBiometrics = biometricHandler.canUseBiometrics(), + biometrics = biometrics, + onBiometricsChange = { + val promptData = if (it) setupPromptData else disablePromptData + biometricHandler.requestBiometrics(promptData) + } ) } @@ -38,9 +72,13 @@ fun SettingsScreen( onBack: () -> Unit, secureMode: Boolean, onSecureModeChange: (Boolean) -> Unit, + pinCode: Boolean, + onPinCodeChange: (Boolean) -> Unit, + showBiometrics: Boolean, + biometrics: Boolean, + onBiometricsChange: (Boolean) -> Unit ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - BackHandler(onBack = onBack) Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -69,7 +107,7 @@ fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { - SettingsSwitch( + SettingsSwitchItem( onCheckedChange = onSecureModeChange, checked = secureMode, title = { @@ -80,6 +118,30 @@ fun SettingsScreen( } ) } + item { + SettingsSwitchItem( + onCheckedChange = onPinCodeChange, + checked = pinCode, + title = { + Text(stringResource(R.string.settings_prefs_pincode)) + }, + description = { + Text(stringResource(R.string.settings_prefs_pincode_description)) + } + ) + } + if (showBiometrics) { + item { + SettingsSwitchItem( + onCheckedChange = onBiometricsChange, + checked = biometrics, + title = { + Text(stringResource(R.string.settings_prefs_biometrics)) + }, + enabled = pinCode + ) + } + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsViewModel.kt b/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsViewModel.kt index 68d058c..6f503a7 100644 --- a/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/xinto/mauth/ui/screen/settings/SettingsViewModel.kt @@ -2,13 +2,16 @@ package com.xinto.mauth.ui.screen.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.xinto.mauth.domain.AuthRepository import com.xinto.mauth.domain.SettingsRepository import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import okhttp3.internal.wait class SettingsViewModel( - private val settings: SettingsRepository + private val settings: SettingsRepository, + private val authRepository: AuthRepository ) : ViewModel() { val secureMode = settings.getSecureMode() @@ -18,9 +21,29 @@ class SettingsViewModel( initialValue = false ) + val pinLock = authRepository.observeIsProtected() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false + ) + + val biometrics = settings.getUseBiometrics() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false + ) + fun updateSecureMode(newSecureMode: Boolean) { viewModelScope.launch { settings.setSecureMode(newSecureMode) } } + + fun toggleBiometrics() { + viewModelScope.launch { + settings.setUseBiometrics(!biometrics.value) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/xinto/mauth/ui/screen/settings/component/SettingsSwitch.kt b/app/src/main/java/com/xinto/mauth/ui/screen/settings/component/SettingsSwitchItem.kt similarity index 93% rename from app/src/main/java/com/xinto/mauth/ui/screen/settings/component/SettingsSwitch.kt rename to app/src/main/java/com/xinto/mauth/ui/screen/settings/component/SettingsSwitchItem.kt index 5a31df3..f5d265d 100644 --- a/app/src/main/java/com/xinto/mauth/ui/screen/settings/component/SettingsSwitch.kt +++ b/app/src/main/java/com/xinto/mauth/ui/screen/settings/component/SettingsSwitchItem.kt @@ -6,15 +6,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable -fun SettingsSwitch( +fun SettingsSwitchItem( + modifier: Modifier = Modifier, onCheckedChange: ((Boolean) -> Unit)?, checked: Boolean, title: @Composable () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, description: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null, - thumbContent: (@Composable () -> Unit)? = null + thumbContent: (@Composable () -> Unit)? = null, + enabled: Boolean = true, ) { val toggleableModifier = if (onCheckedChange != null) { Modifier.toggleable( diff --git a/app/src/main/res/drawable/ic_backspace.xml b/app/src/main/res/drawable/ic_backspace.xml new file mode 100644 index 0000000..b4210bf --- /dev/null +++ b/app/src/main/res/drawable/ic_backspace.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_fingerprint.xml b/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 0000000..27f4e80 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tab.xml b/app/src/main/res/drawable/ic_tab.xml new file mode 100644 index 0000000..37c84a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4c5e43..dc531aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,9 @@ Mauth + Biometric Authentication + Cancel + Nothing here yet Failed to load accounts Add an account @@ -50,4 +53,16 @@ Settings Secure mode Prevents screenshots in the app + PIN Lock + Require authentication on startup + Biometric Authentication + Setup Biometrics + Cancel + Disable Biometrics + Cancel + + Set a PIN + Confirm your PIN + + Enter your PIN \ No newline at end of file