diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a95e4563..57c3ceab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,9 +31,14 @@ android:name=".ui.generate.GeneratePasswordActivity" android:exported="true" /> + + android:windowSoftInputMode="adjustPan"> @@ -44,15 +49,6 @@ android:name="android.app.shortcuts" android:resource="@xml/shortcuts" /> - - (null) + } + + BackHandler(state is AuthState.ConfirmPassword) { + dispatchAction(NavigationAction(AuthState.CreatePassword)) + } + + LaunchedEffect(key1 = Unit, block = { + coroutineScope.launch { + val mPassword = context.getKeyPassPassword() + if (mPassword == null) { + dispatchAction(NavigationAction(AuthState.CreatePassword)) + } + } + }) + + Column( + modifier = Modifier + .padding(32.dp) + .fillMaxSize(1f) + .verticalScroll(rememberScrollState()), + Arrangement.SpaceEvenly, + Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.ic_undraw_unlock_24mb), + contentDescription = "" + ) + + Text(text = stringResource(id = state.title)) + + PasswordInputField( + password, + setPassword, + passwordVisible, + setPasswordVisible, + passwordError + ) + + ButtonBar(state, password, setPasswordError) { + dispatchAction(it) + } + } +} diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/AuthenticationActivity.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/AuthenticationActivity.kt deleted file mode 100644 index 0f6e0541..00000000 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/AuthenticationActivity.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.yogeshpaliyal.keypass.ui.auth - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.BackHandler -import androidx.activity.compose.setContent -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Visibility -import androidx.compose.material.icons.rounded.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.yogeshpaliyal.common.utils.getKeyPassPassword -import com.yogeshpaliyal.common.utils.setKeyPassPassword -import com.yogeshpaliyal.keypass.R -import com.yogeshpaliyal.keypass.ui.nav.DashboardComposeActivity -import com.yogeshpaliyal.keypass.ui.style.KeyPassTheme -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -private const val AUTHENTICATION_RESULT = 707 - -sealed class AuthState(@StringRes val title: Int) { - object CreatePassword : AuthState(R.string.create_password) - class ConfirmPassword(val password: String) : AuthState(R.string.confirm_password) - object Login : AuthState(R.string.login_to_enter_keypass) -} - -@AndroidEntryPoint -class AuthenticationActivity : AppCompatActivity() { - - @Preview(showSystemUi = true) - @Composable - fun AuthScreenPreview() { - AuthScreen() - } - - @Composable - fun AuthScreen() { - val context = LocalContext.current - - val coroutineScope = rememberCoroutineScope() - - val (state, setState) = remember { - mutableStateOf(AuthState.Login) - } - - val (password, setPassword) = remember(state) { - mutableStateOf("") - } - - val (passwordVisible, setPasswordVisible) = remember(state) { mutableStateOf(false) } - - val (passwordError, setPasswordError) = remember(state, password) { - mutableStateOf(null) - } - - BackHandler(state is AuthState.ConfirmPassword) { - setState(AuthState.CreatePassword) - } - - LaunchedEffect(key1 = Unit, block = { - coroutineScope.launch { - val mPassword = context.getKeyPassPassword() - if (mPassword == null) { - setState(AuthState.CreatePassword) - } - } - }) - - KeyPassTheme { - Column( - modifier = Modifier - .padding(32.dp) - .fillMaxSize(1f) - .verticalScroll(rememberScrollState()), - Arrangement.SpaceEvenly, - Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(R.drawable.ic_undraw_unlock_24mb), - contentDescription = "" - ) - - Text(text = stringResource(id = state.title)) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(1f), - value = password, - singleLine = true, - placeholder = { - Text(text = stringResource(id = R.string.enter_password)) - }, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - onValueChange = setPassword, - isError = passwordError != null, - supportingText = { - if (passwordError != null) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = passwordError), - color = MaterialTheme.colorScheme.error - ) - } - }, - trailingIcon = { - val image = if (passwordVisible) { - Icons.Rounded.Visibility - } else Icons.Rounded.VisibilityOff - - // Please provide localized description for accessibility services - val description = if (passwordVisible) "Hide password" else "Show password" - - IconButton(onClick = { setPasswordVisible(!passwordVisible) }) { - Icon(imageVector = image, description) - } - } - ) - - Row(modifier = Modifier.fillMaxWidth(1f), Arrangement.SpaceEvenly) { - AnimatedVisibility(state is AuthState.ConfirmPassword) { - Button(onClick = { - setState(AuthState.CreatePassword) - }) { - Text(text = stringResource(id = R.string.back)) - } - } - - Button(onClick = { - when (state) { - is AuthState.CreatePassword -> { - if (password.isBlank()) { - setPasswordError(R.string.enter_password) - } else { - setState(AuthState.ConfirmPassword(password)) - } - } - - is AuthState.ConfirmPassword -> { - if (state.password == password) { - coroutineScope.launch { - context.setKeyPassPassword(password) - onAuthComplete(context) - } - } else { - setPasswordError(R.string.password_no_match) - } - } - - is AuthState.Login -> { - coroutineScope.launch { - val savedPassword = context.getKeyPassPassword() - if (savedPassword == password) { - onAuthComplete(context) - } else { - setPasswordError(R.string.incorrect_password) - } - } - } - } - }) { - Text(text = stringResource(id = R.string.str_continue)) - } - } - } - } - } - - private fun onAuthComplete(context: Context) { - // binding.passCodeView.isVisible = false - val dashboardIntent = Intent(context, DashboardComposeActivity::class.java) - startActivity(dashboardIntent) - finish() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - AuthScreen() - } - } -} diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/components/ButtonBar.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/components/ButtonBar.kt new file mode 100644 index 00000000..241c557a --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/components/ButtonBar.kt @@ -0,0 +1,77 @@ +package com.yogeshpaliyal.keypass.ui.auth.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.yogeshpaliyal.common.utils.getKeyPassPassword +import com.yogeshpaliyal.common.utils.setKeyPassPassword +import com.yogeshpaliyal.keypass.R +import com.yogeshpaliyal.keypass.ui.redux.AuthState +import com.yogeshpaliyal.keypass.ui.redux.HomeState +import com.yogeshpaliyal.keypass.ui.redux.NavigationAction +import kotlinx.coroutines.launch + +@Composable +fun ButtonBar( + state: AuthState, + password: String, + setPasswordError: (Int?) -> Unit, + dispatchAction: (NavigationAction) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + Row(modifier = Modifier.fillMaxWidth(1f), Arrangement.SpaceEvenly) { + AnimatedVisibility(state is AuthState.ConfirmPassword) { + Button(onClick = { + dispatchAction(NavigationAction(AuthState.CreatePassword)) + }) { + Text(text = stringResource(id = R.string.back)) + } + } + + Button(onClick = { + when (state) { + is AuthState.CreatePassword -> { + if (password.isBlank()) { + setPasswordError(R.string.enter_password) + } else { + dispatchAction(NavigationAction(AuthState.ConfirmPassword(password))) + } + } + + is AuthState.ConfirmPassword -> { + if (state.password == password) { + coroutineScope.launch { + context.setKeyPassPassword(password) + dispatchAction(NavigationAction(HomeState(), true)) + } + } else { + setPasswordError(R.string.password_no_match) + } + } + + is AuthState.Login -> { + coroutineScope.launch { + val savedPassword = context.getKeyPassPassword() + if (savedPassword == password) { + dispatchAction(NavigationAction(HomeState(), true)) + } else { + setPasswordError(R.string.incorrect_password) + } + } + } + } + }) { + Text(text = stringResource(id = R.string.str_continue)) + } + } +} diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/components/PasswordInputField.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/components/PasswordInputField.kt new file mode 100644 index 00000000..bd2e2828 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/auth/components/PasswordInputField.kt @@ -0,0 +1,62 @@ +package com.yogeshpaliyal.keypass.ui.auth.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import com.yogeshpaliyal.keypass.R + +@Composable +fun PasswordInputField( + password: String, + setPassword: (String) -> Unit, + passwordVisible: Boolean, + setPasswordVisible: (Boolean) -> Unit, + passwordError: Int? +) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(1f), + value = password, + singleLine = true, + placeholder = { + Text(text = stringResource(id = R.string.enter_password)) + }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + onValueChange = setPassword, + isError = passwordError != null, + supportingText = { + if (passwordError != null) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = passwordError), + color = MaterialTheme.colorScheme.error + ) + } + }, + trailingIcon = { + val image = if (passwordVisible) { + Icons.Rounded.Visibility + } else Icons.Rounded.VisibilityOff + + // Please provide localized description for accessibility services + val description = if (passwordVisible) "Hide password" else "Show password" + + IconButton(onClick = { setPasswordVisible(!passwordVisible) }) { + Icon(imageVector = image, description) + } + } + ) +} diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/DashboardViewModel.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/DashboardViewModel.kt index ce7f52a2..5b9a2ff6 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/DashboardViewModel.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/DashboardViewModel.kt @@ -1,10 +1,13 @@ package com.yogeshpaliyal.keypass.ui.home import android.app.Application +import android.content.ContentResolver +import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.viewModelScope import com.yogeshpaliyal.common.data.AccountModel +import com.yogeshpaliyal.common.dbhelper.restoreBackup import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -27,7 +30,12 @@ class DashboardViewModel @Inject constructor( val mediator = MediatorLiveData>() - fun queryUpdated(keyword: String?, tag: String?, sortField: String?, sortAscending: Boolean = true) { + fun queryUpdated( + keyword: String?, + tag: String?, + sortField: String?, + sortAscending: Boolean = true + ) { viewModelScope.launch(Dispatchers.IO) { if (sortAscending) { mediator.postValue(appDao.getAllAccountsAscending(keyword ?: "", tag, sortField)) @@ -36,4 +44,12 @@ class DashboardViewModel @Inject constructor( } } } + + suspend fun restoreBackup( + keyphrase: String, + contentResolver: ContentResolver, + fileUri: Uri? + ): Boolean { + return appDb.restoreBackup(keyphrase, contentResolver, fileUri) + } } diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/Homepage.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/Homepage.kt index 1d9f077f..8f92a013 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/Homepage.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/Homepage.kt @@ -1,5 +1,6 @@ package com.yogeshpaliyal.keypass.ui.home +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -67,14 +68,14 @@ fun Homepage( ) } - if (tag != null) { + AnimatedVisibility(tag != null) { LazyRow( modifier = Modifier.padding(vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp), content = { item { AssistChip(onClick = { }, label = { - Text(text = tag) + Text(text = tag ?: "") }, trailingIcon = { IconButton(onClick = { dispatchAction(NavigationAction(HomeState(), true)) diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/AccountsList.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/AccountsList.kt index 00e3dadc..f7e85e5f 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/AccountsList.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/AccountsList.kt @@ -1,10 +1,13 @@ package com.yogeshpaliyal.keypass.ui.home.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -53,31 +56,35 @@ import kotlinx.coroutines.delay import org.reduxkotlin.compose.rememberDispatcher import kotlin.time.Duration.Companion.seconds +@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) @Composable fun AccountsList(accounts: List? = null) { val dispatch = rememberDispatcher() if (accounts?.isNotEmpty() == true) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(accounts) { account -> - Account( - account, - onClick = { - if (it.type == AccountType.TOTP) { - dispatch(IntentNavigation.AddTOTP(it.uniqueId)) - } else { - dispatch(NavigationAction(AccountDetailState(it.id))) + AnimatedContent(targetState = accounts) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(it) { account -> + Account( + modifier = Modifier.animateItemPlacement(), + account, + onClick = { + if (it.type == AccountType.TOTP) { + dispatch(IntentNavigation.AddTOTP(it.uniqueId)) + } else { + dispatch(NavigationAction(AccountDetailState(it.id))) + } } - } - ) - } - item { - Spacer(modifier = Modifier.height(8.dp)) + ) + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } } } } else { @@ -87,12 +94,14 @@ fun AccountsList(accounts: List? = null) { @Composable fun Account( + modifier: Modifier, accountModel: AccountModel, onClick: (AccountModel) -> Unit ) { val dispatch = rememberDispatcher() Card( + modifier = modifier, elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), onClick = { onClick(accountModel) } ) { @@ -218,7 +227,8 @@ fun WrapWithProgress(accountModel: AccountModel) { return } - val infiniteTransition = rememberInfiniteTransition(accountModel.uniqueId ?: accountModel.title ?: "") + val infiniteTransition = + rememberInfiniteTransition(accountModel.uniqueId ?: accountModel.title ?: "") val rotationAnimation = infiniteTransition.animateFloat( initialValue = 1f - (accountModel.getTOtpProgress().toFloat() / 30), targetValue = 1f, diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/SearchBar.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/SearchBar.kt index ecf5e005..cbbf89d4 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/SearchBar.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/home/components/SearchBar.kt @@ -1,5 +1,6 @@ package com.yogeshpaliyal.keypass.ui.home.components +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -40,7 +41,7 @@ fun SearchBar( }, trailingIcon = { Row { - if (keyword.isNullOrBlank().not()) { + AnimatedVisibility(keyword.isNullOrBlank().not()) { IconButton(onClick = { updateKeyword("") }) { Icon( painter = rememberVectorPainter(image = Icons.Rounded.Close), diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/nav/DashboardComposeActivity.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/nav/DashboardComposeActivity.kt index 8dcade06..e990f1a5 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/nav/DashboardComposeActivity.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/nav/DashboardComposeActivity.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -45,12 +46,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidViewBinding import com.yogeshpaliyal.keypass.BuildConfig -import com.yogeshpaliyal.keypass.databinding.LayoutMySettingsFragmentBinding +import com.yogeshpaliyal.keypass.ui.auth.AuthScreen import com.yogeshpaliyal.keypass.ui.detail.AccountDetailPage import com.yogeshpaliyal.keypass.ui.home.Homepage import com.yogeshpaliyal.keypass.ui.redux.AccountDetailState +import com.yogeshpaliyal.keypass.ui.redux.AuthState import com.yogeshpaliyal.keypass.ui.redux.BottomSheetAction import com.yogeshpaliyal.keypass.ui.redux.BottomSheetState import com.yogeshpaliyal.keypass.ui.redux.GoBackAction @@ -62,6 +63,7 @@ import com.yogeshpaliyal.keypass.ui.redux.ScreenState import com.yogeshpaliyal.keypass.ui.redux.SettingsState import com.yogeshpaliyal.keypass.ui.redux.TotpDetailState import com.yogeshpaliyal.keypass.ui.redux.UpdateContextAction +import com.yogeshpaliyal.keypass.ui.settings.MySettingCompose import com.yogeshpaliyal.keypass.ui.style.KeyPassTheme import dagger.hilt.android.AndroidEntryPoint import org.reduxkotlin.compose.StoreProvider @@ -101,9 +103,11 @@ fun Dashboard() { dispatch(GoBackAction) } - if (systemBackPress) { - (context as? AppCompatActivity)?.onBackPressed() - } + LaunchedEffect(key1 = systemBackPress, block = { + if (systemBackPress) { + (context as? AppCompatActivity)?.onBackPressed() + } + }) DisposableEffect(KeyPassRedux, context) { dispatch(UpdateContextAction(context)) @@ -128,27 +132,27 @@ fun Dashboard() { fun CurrentPage() { val currentScreen by selectState { this.currentScreen } - when (currentScreen) { - is HomeState -> { - Homepage(homeState = (currentScreen as HomeState)) - } + currentScreen.let { + when (it) { + is HomeState -> { + Homepage(homeState = it) + } - is SettingsState -> { - MySettings() - } + is SettingsState -> { + MySettingCompose() + } - is AccountDetailState -> { - AccountDetailPage(id = (currentScreen as AccountDetailState).accountId) - } + is AccountDetailState -> { + AccountDetailPage(id = it.accountId) + } - is TotpDetailState -> { - } - } -} + is AuthState -> { + AuthScreen(it) + } -@Composable -fun MySettings() { - AndroidViewBinding(LayoutMySettingsFragmentBinding::inflate) { + is TotpDetailState -> { + } + } } } @@ -156,9 +160,9 @@ fun MySettings() { fun OptionBottomBar( viewModel: BottomNavViewModel = androidx.lifecycle.viewmodel.compose.viewModel() ) { - val bottomSheetState by selectState { this.bottomSheet } + val bottomSheetState by selectState { this.bottomSheet } - if (!bottomSheetState.isBottomSheetOpen) { + if (bottomSheetState?.isBottomSheetOpen != true) { return } @@ -230,7 +234,9 @@ fun NavItem(item: NavigationModelItem.NavMenuItem, onClick: () -> Unit) { interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = true), onClick = onClick - ).padding(16.dp).fillMaxWidth(1f) + ) + .padding(16.dp) + .fillMaxWidth(1f) ) { Icon( painter = painterResource(id = item.icon), diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/Action.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/Action.kt index c9585946..fab02cae 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/Action.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/Action.kt @@ -15,6 +15,8 @@ data class StateUpdateAction(val state: ScreenState) : Action sealed interface IntentNavigation : Action { object GeneratePassword : IntentNavigation + object BackupActivity : IntentNavigation + object ShareApp : IntentNavigation data class AddTOTP(val accountId: String? = null) : IntentNavigation } diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/KeyPassRedux.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/KeyPassRedux.kt index 6fe74d74..0fb222dc 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/KeyPassRedux.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/KeyPassRedux.kt @@ -5,8 +5,10 @@ import android.content.ClipboardManager import android.content.Intent import android.widget.Toast import androidx.core.content.ContextCompat +import com.yogeshpaliyal.keypass.BuildConfig import com.yogeshpaliyal.keypass.R import com.yogeshpaliyal.keypass.ui.addTOTP.AddTOTPActivity +import com.yogeshpaliyal.keypass.ui.backup.BackupActivity import com.yogeshpaliyal.keypass.ui.generate.GeneratePasswordActivity import org.reduxkotlin.Reducer import org.reduxkotlin.applyMiddleware @@ -56,6 +58,13 @@ object KeyPassRedux { state.copy(context = action.context) } + is ToastAction -> { + state.context?.let { + Toast.makeText(it, action.text, Toast.LENGTH_SHORT).show() + } + state + } + is GoBackAction -> { val lastItem = arrPages.removeLastOrNull() if (lastItem != null) { @@ -91,6 +100,21 @@ object KeyPassRedux { is IntentNavigation.AddTOTP -> { AddTOTPActivity.start(state.context, action.accountId) } + + is IntentNavigation.BackupActivity -> { + BackupActivity.start(state.context) + } + + is IntentNavigation.ShareApp -> { + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra( + Intent.EXTRA_TEXT, + "KeyPass Password Manager\n Offline, Secure, Open Source https://play.google.com/store/apps/details?id=" + BuildConfig.APPLICATION_ID + ) + sendIntent.type = "text/plain" + state.context?.startActivity(Intent.createChooser(sendIntent, state.context.getString(R.string.share_keypass))) + } } next(action) } diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/State.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/State.kt index 08cf046b..f38fc668 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/State.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/redux/State.kt @@ -2,11 +2,13 @@ package com.yogeshpaliyal.keypass.ui.redux import android.content.Context import android.os.Bundle +import androidx.annotation.StringRes +import com.yogeshpaliyal.keypass.R data class KeyPassState( val context: Context? = null, val currentScreen: ScreenState, - val bottomSheet: BottomSheetState, + val bottomSheet: BottomSheetState? = null, val systemBackPress: Boolean = false ) @@ -17,6 +19,11 @@ data class AccountDetailState(val accountId: Long? = null) : ScreenState() data class TotpDetailState(val accountId: String? = null) : ScreenState() object SettingsState : ScreenState(true) +open class AuthState(@StringRes val title: Int) : ScreenState(false) { + object CreatePassword : AuthState(R.string.create_password) + class ConfirmPassword(val password: String) : AuthState(R.string.confirm_password) + object Login : AuthState(R.string.login_to_enter_keypass) +} data class BottomSheetState( val path: String, val args: Bundle? = null, @@ -24,7 +31,7 @@ data class BottomSheetState( ) fun generateDefaultState(): KeyPassState { - val currentPage = HomeState() + val currentPage = AuthState.Login val bottomSheet = BottomSheetState(BottomSheetRoutes.HOME_NAV_MENU, isBottomSheetOpen = false) return KeyPassState(currentScreen = currentPage, bottomSheet = bottomSheet) } diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsFragment.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsFragment.kt index 674ba643..0373554e 100644 --- a/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsFragment.kt +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsFragment.kt @@ -1,39 +1,59 @@ package com.yogeshpaliyal.keypass.ui.settings -import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager -import android.content.ContentResolver -import android.content.DialogInterface -import android.content.Intent import android.net.Uri import android.os.Bundle -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat.getSystemService -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.lifecycleScope -import androidx.preference.Preference +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Feedback +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.yogeshpaliyal.common.dbhelper.createBackup -import com.yogeshpaliyal.common.dbhelper.restoreBackup import com.yogeshpaliyal.common.utils.BACKUP_KEY_LENGTH import com.yogeshpaliyal.common.utils.email -import com.yogeshpaliyal.common.utils.getOrCreateBackupKey -import com.yogeshpaliyal.common.utils.setBackupDirectory -import com.yogeshpaliyal.keypass.BuildConfig import com.yogeshpaliyal.keypass.R -import com.yogeshpaliyal.keypass.databinding.LayoutBackupKeypharseBinding -import com.yogeshpaliyal.keypass.databinding.LayoutRestoreKeypharseBinding -import com.yogeshpaliyal.keypass.ui.backup.BackupActivity +import com.yogeshpaliyal.keypass.ui.home.DashboardViewModel +import com.yogeshpaliyal.keypass.ui.redux.Action +import com.yogeshpaliyal.keypass.ui.redux.IntentNavigation +import com.yogeshpaliyal.keypass.ui.redux.ToastAction import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.reduxkotlin.compose.rememberTypedDispatcher import javax.inject.Inject -private const val CHOOSE_BACKUPS_LOCATION_REQUEST_CODE = 26212 -private const val CHOOSE_RESTORE_FILE_REQUEST_CODE = 26213 - @AndroidEntryPoint class MySettingsFragment : PreferenceFragmentCompat() { @@ -43,202 +63,181 @@ class MySettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences, rootKey) } +} - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - "feedback" -> { - context?.email( - getString(R.string.feedback_to_keypass), - "yogeshpaliyal.foss@gmail.com" - ) - true - } - - "backup" -> { - BackupActivity.start(context) - true - } - - getString(R.string.settings_restore_backup) -> { - selectRestoreFile() - true - } - - "share" -> { - val sendIntent = Intent() - sendIntent.action = Intent.ACTION_SEND - sendIntent.putExtra( - Intent.EXTRA_TEXT, - "KeyPass Password Manager\n Offline, Secure, Open Source https://play.google.com/store/apps/details?id=" + BuildConfig.APPLICATION_ID - ) - sendIntent.type = "text/plain" - startActivity(Intent.createChooser(sendIntent, getString(R.string.share_keypass))) - true - } - else -> super.onPreferenceTreeClick(preference) - } +@Composable +fun RestoreDialog( + selectedFile: Uri, + hideDialog: () -> Unit, + mViewModel: DashboardViewModel = hiltViewModel() +) { + val (keyphrase, setKeyPhrase) = remember { + mutableStateOf("") } - private fun selectRestoreFile() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "*/*" + val dispatchAction = rememberTypedDispatcher() - intent.addFlags( - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_READ_URI_PERMISSION + val context = LocalContext.current + + val coroutineScope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { + hideDialog() + }, + title = { + Text(text = stringResource(id = R.string.restore)) + }, + confirmButton = { + TextButton(onClick = { + if (keyphrase.isEmpty()) { + dispatchAction(ToastAction(R.string.alert_blank_keyphrase)) + return@TextButton + } + + if (keyphrase.length != BACKUP_KEY_LENGTH) { + dispatchAction(ToastAction(R.string.alert_invalid_keyphrase)) + return@TextButton + } + coroutineScope.launch { + val result = + mViewModel.restoreBackup(keyphrase, context.contentResolver, selectedFile) + + if (result) { + hideDialog() + dispatchAction(ToastAction(R.string.backup_restored)) + } else { + dispatchAction(ToastAction(R.string.invalid_keyphrase)) + } + } + }) { + Text(text = stringResource(id = R.string.restore)) + } + }, + dismissButton = { + TextButton(onClick = hideDialog) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth(1f)) { + Text(text = stringResource(id = R.string.keyphrase_restore_info)) + Spacer(modifier = Modifier.size(8.dp)) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(1f), + value = keyphrase, + onValueChange = setKeyPhrase, + placeholder = { + Text(text = stringResource(id = R.string.enter_keyphrase)) + } + ) + } + } + ) +} + +@Preview(showSystemUi = true) +@Composable +fun MySettingCompose() { + val dispatchAction = rememberTypedDispatcher() + val context = LocalContext.current + + val (result, setResult) = remember { mutableStateOf(null) } + + val launcher = rememberLauncherForActivityResult(OpenKeyPassBackup()) { + setResult(it) + } + + result?.let { + RestoreDialog( + selectedFile = it, + hideDialog = { + setResult(null) + } ) - - try { - startActivityForResult(intent, CHOOSE_RESTORE_FILE_REQUEST_CODE) - } catch (e: Exception) { - e.printStackTrace() - } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == CHOOSE_BACKUPS_LOCATION_REQUEST_CODE && resultCode == Activity.RESULT_OK) { - val contentResolver = context?.contentResolver - val selectedDirectory = data?.data - if (contentResolver != null && selectedDirectory != null) { - contentResolver.takePersistableUriPermission( - selectedDirectory, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - - lifecycleScope.launch { - context?.setBackupDirectory(selectedDirectory.toString()) - - backup(selectedDirectory) - } - } - } else if (requestCode == CHOOSE_RESTORE_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { - val contentResolver = context?.contentResolver - val selectedFile = data?.data - if (contentResolver != null && selectedFile != null) { - val binding = LayoutRestoreKeypharseBinding.inflate(layoutInflater) - - val dialog = MaterialAlertDialogBuilder(requireContext()).setView(binding.root) - .setNegativeButton( - R.string.cancel - ) { dialog, which -> - dialog.dismiss() - } - .setPositiveButton( - R.string.restore - ) { dialog, which -> - dialog.dismiss() - }.create() - - dialog.setOnShowListener { - val positiveBtn = dialog.getButton(DialogInterface.BUTTON_POSITIVE) - positiveBtn.setOnClickListener { - restore( - dialog, - binding.etKeyPhrase.text.toString(), - contentResolver, - selectedFile - ) - } - } - dialog.show() - } + Column { + PreferenceItem(title = R.string.security, isCategory = true) + PreferenceItem( + title = R.string.credentials_backups, + summary = R.string.credentials_backups_desc + ) { + dispatchAction(IntentNavigation.BackupActivity) + } + PreferenceItem( + title = R.string.restore_credentials, + summary = R.string.restore_credentials_desc + ) { + launcher.launch(arrayOf()) + } + Divider( + modifier = Modifier + .fillMaxWidth(1f) + .height(1.dp) + ) + PreferenceItem(title = R.string.help, isCategory = true) + PreferenceItem( + title = R.string.send_feedback, + summary = R.string.send_feedback_desc, + icon = Icons.Rounded.Feedback + ) { + context.email( + context.getString(R.string.feedback_to_keypass), + "yogeshpaliyal.foss@gmail.com" + ) + } + PreferenceItem( + title = R.string.share, + summary = R.string.share_desc, + icon = Icons.Rounded.Share + ) { + dispatchAction(IntentNavigation.ShareApp) } } +} - private fun restore( - dialog: AlertDialog, - keyphrase: String, - contentResolver: ContentResolver, - selectedFile: Uri +@Composable +fun PreferenceItem( + @StringRes title: Int, + @StringRes summary: Int? = null, + icon: ImageVector? = null, + isCategory: Boolean = false, + onClickItem: (() -> Unit)? = null +) { + val titleColor = if (isCategory) { + MaterialTheme.colorScheme.secondary + } else { + Color.Unspecified + } + + Row( + modifier = Modifier + .fillMaxWidth(1f) + .widthIn(48.dp) + .padding(horizontal = 16.dp) + .clickable(onClickItem != null) { + onClickItem?.invoke() + }, + verticalAlignment = Alignment.CenterVertically ) { - if (keyphrase.isEmpty()) { - Toast.makeText( - context, - R.string.alert_blank_keyphrase, - Toast.LENGTH_SHORT - ) - .show() - return - } - - if (keyphrase.length != BACKUP_KEY_LENGTH) { - Toast.makeText( - context, - R.string.alert_invalid_keyphrase, - Toast.LENGTH_SHORT - ).show() - return - } - lifecycleScope.launch { - val result = appDb.restoreBackup( - keyphrase, - contentResolver, - selectedFile - ) - if (result) { - dialog.dismiss() - Toast.makeText( - context, - getString(R.string.backup_restored), - Toast.LENGTH_SHORT - ).show() - } else { - Toast.makeText( - context, - getString(R.string.invalid_keyphrase), - Toast.LENGTH_SHORT - ).show() + Box(modifier = Modifier.width(56.dp), Alignment.CenterStart) { + if (icon != null) { + Icon(painter = rememberVectorPainter(image = icon), contentDescription = "") } } - } - - suspend fun backup(selectedDirectory: Uri) { - val keyPair = requireContext().getOrCreateBackupKey() - - val tempFile = DocumentFile.fromTreeUri(requireContext(), selectedDirectory)?.createFile( - "*/*", - "key_pass_backup_${System.currentTimeMillis()}.keypass" - ) - - lifecycleScope.launch { - context?.contentResolver?.let { - appDb.createBackup( - keyPair.second, - it, - tempFile?.uri - ) - if (keyPair.first) { - val binding = LayoutBackupKeypharseBinding.inflate(layoutInflater) - binding.txtCode.text = requireContext().getOrCreateBackupKey().second - binding.txtCode.setOnClickListener { - val clipboard = - getSystemService(requireContext(), ClipboardManager::class.java) - val clip = ClipData.newPlainText( - getString(R.string.app_name), - binding.txtCode.text - ) - clipboard?.setPrimaryClip(clip) - Toast.makeText( - context, - getString(R.string.copied_to_clipboard), - Toast.LENGTH_SHORT - ).show() - } - MaterialAlertDialogBuilder(requireContext()).setView(binding.root) - .setPositiveButton( - "Yes" - ) { dialog, which -> - dialog?.dismiss() - }.show() - } else { - Toast.makeText( - context, - getString(R.string.backup_completed), - Toast.LENGTH_SHORT - ).show() - } + Column( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth(1f) + ) { + Text( + text = stringResource(id = title), + color = titleColor, + style = TextStyle(fontSize = 16.sp) + ) + if (summary != null) { + Text(text = stringResource(id = summary), style = TextStyle(fontSize = 14.sp)) } } } diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsViewModel.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsViewModel.kt new file mode 100644 index 00000000..447883ea --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/MySettingsViewModel.kt @@ -0,0 +1,14 @@ +package com.yogeshpaliyal.keypass.ui.settings + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MySettingsViewModel @Inject constructor( + application: Application, + val appDb: com.yogeshpaliyal.common.AppDatabase +) : AndroidViewModel(application) { + private val appDao = appDb.getDao() +} diff --git a/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/OpenKeyPassBackup.kt b/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/OpenKeyPassBackup.kt new file mode 100644 index 00000000..84b06eb5 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/keypass/ui/settings/OpenKeyPassBackup.kt @@ -0,0 +1,20 @@ +package com.yogeshpaliyal.keypass.ui.settings + +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContracts + +class OpenKeyPassBackup : ActivityResultContracts.OpenDocument() { + override fun createIntent(context: Context, input: Array): Intent { + super.createIntent(context, input) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "*/*" + + intent.addFlags( + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + return intent + } +}