Migrate TOTP screen to Compose from XML (#616)

* migrate totp

* formatting fixes
This commit is contained in:
Yogesh Choudhary Paliyal
2023-06-24 15:51:14 +05:30
committed by GitHub
parent 9cc2ac9860
commit 9ec302908d
11 changed files with 198 additions and 257 deletions

View File

@@ -20,9 +20,6 @@
<activity
android:name=".ui.CrashActivity"
android:exported="false" />
<activity
android:name=".ui.addTOTP.AddTOTPActivity"
android:exported="false" />
<activity
android:name=".ui.addTOTP.ScannerActivity"
android:exported="false" />

View File

@@ -1,142 +1,202 @@
package com.yogeshpaliyal.keypass.ui.addTOTP
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.zxing.integration.android.IntentIntegrator
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBackIosNew
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.common.utils.TOTPHelper
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.databinding.ActivityAddTotpactivityBinding
import dagger.hilt.android.AndroidEntryPoint
import com.yogeshpaliyal.keypass.ui.detail.DeleteConfirmation
import com.yogeshpaliyal.keypass.ui.detail.KeyPassInputField
import com.yogeshpaliyal.keypass.ui.detail.QRScanner
import com.yogeshpaliyal.keypass.ui.redux.actions.Action
import com.yogeshpaliyal.keypass.ui.redux.actions.GoBackAction
import com.yogeshpaliyal.keypass.ui.redux.actions.ToastAction
import org.reduxkotlin.compose.rememberTypedDispatcher
@AndroidEntryPoint
class AddTOTPActivity : AppCompatActivity() {
@Composable
fun TOTPScreen(id: String? = null, viewModel: AddTOTPViewModel = hiltViewModel()) {
val dispatchAction = rememberTypedDispatcher<Action>()
companion object {
private const val ARG_ACCOUNT_ID = "account_id"
@JvmStatic
fun start(context: Context?, accountId: String? = null) {
val starter = Intent(context, AddTOTPActivity::class.java)
starter.putExtra(ARG_ACCOUNT_ID, accountId)
context?.startActivity(starter)
}
}
private lateinit var binding: ActivityAddTotpactivityBinding
private val mViewModel by viewModels<AddTOTPViewModel>()
private val accountId by lazy {
intent.extras?.getString(ARG_ACCOUNT_ID)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAddTotpactivityBinding.inflate(layoutInflater)
binding.mViewModel = mViewModel
binding.lifecycleOwner = this
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.tilSecretKey.isVisible = accountId == null
mViewModel.loadOldAccount(accountId)
binding.toolbar.setNavigationOnClickListener {
onBackPressed()
}
binding.tilSecretKey.setEndIconOnClickListener {
// ScannerActivity.start(this)
IntentIntegrator(this).setPrompt("").initiateScan()
}
mViewModel.error.observe(
this,
Observer {
it?.getContentIfNotHandled()?.let {
Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show()
}
}
// task value state
val (accountModel, setAccountModel) = remember {
mutableStateOf(
AccountModel()
)
}
mViewModel.goBack.observe(
this,
Observer {
it.getContentIfNotHandled()?.let {
onBackPressed()
}
}
)
binding.btnSave.setOnClickListener {
mViewModel.saveAccount(accountId)
val launcher = rememberLauncherForActivityResult(QRScanner()) {
it?.let {
val totp = TOTPHelper(it)
setAccountModel(
accountModel.copy(
password = totp.secret,
title = totp.label,
username = totp.issuer
)
)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
if (accountId != null) {
menuInflater.inflate(R.menu.menu_delete, menu)
}
return super.onCreateOptionsMenu(menu)
val goBack: () -> Unit = {
dispatchAction(GoBackAction)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_delete) {
deleteAccount()
// Set initial object
LaunchedEffect(key1 = Unit) {
viewModel.loadOldAccount(id) {
setAccountModel(it.copy())
}
return super.onOptionsItemSelected(item)
}
private fun deleteAccount() {
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.delete_account_title))
.setMessage(getString(R.string.delete_account_msg))
.setPositiveButton(
getString(R.string.delete)
) { dialog, which ->
dialog?.dismiss()
if (accountId != null) {
mViewModel.deleteAccount(accountId!!) {
onBackPressed()
Scaffold(bottomBar = {
BottomBar(
backPressed = goBack,
showDeleteButton = accountModel.uniqueId != null,
onDeletePressed = {
accountModel.uniqueId?.let {
viewModel.deleteAccount(it) {
goBack()
}
}
}
.setNegativeButton(getString(R.string.cancel)) { dialog, which ->
dialog.dismiss()
}.show()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
if (result.contents != null) {
try {
val totp = TOTPHelper(result.contents)
totp.secret?.let {
mViewModel.setSecretKey(it)
}
mViewModel.setAccountName(totp.label)
} catch (e: Exception) {
e.printStackTrace()
}
) {
if (accountModel.password.isNullOrEmpty()) {
dispatchAction(ToastAction(R.string.alert_black_secret_key))
return@BottomBar
}
if (accountModel.title.isNullOrEmpty()) {
dispatchAction(ToastAction(R.string.alert_black_account_name))
return@BottomBar
}
viewModel.saveAccount(accountModel, goBack)
}
}) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
Fields(accountModel = accountModel, updateAccountModel = { newAccountModel ->
setAccountModel(newAccountModel)
}) {
launcher.launch(null)
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
}
@Composable
fun Fields(
modifier: Modifier = Modifier,
accountModel: AccountModel,
updateAccountModel: (newAccountModel: AccountModel) -> Unit,
scanClicked: () -> Unit
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (accountModel.uniqueId == null) {
KeyPassInputField(
modifier = Modifier.testTag("secretKey"),
placeholder = R.string.secret_key,
value = accountModel.password,
setValue = {
updateAccountModel(accountModel.copy(password = it))
},
trailingIcon = {
IconButton(onClick = {
scanClicked()
}) {
Icon(
painter = painterResource(id = R.drawable.ic_twotone_qr_code_scanner_24),
contentDescription = ""
)
}
}
)
}
KeyPassInputField(
modifier = Modifier.testTag("accountName"),
placeholder = R.string.account_name,
value = accountModel.title,
setValue = {
updateAccountModel(accountModel.copy(title = it))
}
)
}
}
@Composable
fun BottomBar(
backPressed: () -> Unit,
showDeleteButton: Boolean,
onDeletePressed: () -> Unit,
onSaveClicked: () -> Unit
) {
val (openDialog, setOpenDialog) = remember { mutableStateOf(false) }
BottomAppBar(actions = {
IconButton(onClick = backPressed) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.ArrowBackIosNew),
contentDescription = "Go Back",
tint = MaterialTheme.colorScheme.onSurface
)
}
if (showDeleteButton) {
IconButton(onClick = {
setOpenDialog(true)
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Delete),
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}, floatingActionButton = {
FloatingActionButton(modifier = Modifier.testTag("save"), onClick = onSaveClicked) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Done),
contentDescription = "Save Changes"
)
}
})
DeleteConfirmation(
openDialog,
updateDialogVisibility = {
setOpenDialog(it)
},
onDeletePressed
)
}

View File

@@ -1,14 +1,11 @@
package com.yogeshpaliyal.keypass.ui.addTOTP
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.yogeshpaliyal.common.AppDatabase
import com.yogeshpaliyal.common.constants.AccountType
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.common.utils.Event
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.common.utils.getRandomString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -18,64 +15,32 @@ import javax.inject.Inject
class AddTOTPViewModel @Inject constructor(private val appDatabase: AppDatabase) :
ViewModel() {
private val _goBack = MutableLiveData<Event<Unit>>()
val goBack: LiveData<Event<Unit>> = _goBack
private val _error = MutableLiveData<Event<Int>>()
val error: LiveData<Event<Int>> = _error
val secretKey = MutableLiveData<String>("")
val accountName = MutableLiveData<String>("")
fun loadOldAccount(accountId: String?) {
fun loadOldAccount(accountId: String?, loadAccount: (accountModel: AccountModel) -> Unit) {
accountId ?: return
viewModelScope.launch(Dispatchers.IO) {
appDatabase.getDao().getAccount(accountId)?.let { accountModel ->
accountName.postValue(accountModel.title ?: "")
loadAccount(accountModel)
}
}
}
fun saveAccount(accountId: String?) {
fun saveAccount(accountModel: AccountModel, onComplete: () -> Unit) {
viewModelScope.launch {
val secretKey = secretKey.value
val accountName = accountName.value
if (accountId == null) {
if (secretKey.isNullOrEmpty()) {
_error.postValue(Event(R.string.alert_black_secret_key))
return@launch
}
}
if (accountName.isNullOrEmpty()) {
_error.postValue(Event(R.string.alert_black_account_name))
return@launch
}
val accountModel = if (accountId == null) {
AccountModel(password = secretKey, title = accountName, type = AccountType.TOTP)
val accountModelDb = if (accountModel.uniqueId == null) {
AccountModel(uniqueId = getRandomString(), password = accountModel.password, title = accountModel.title, type = AccountType.TOTP)
} else {
appDatabase.getDao().getAccount(accountId)?.also {
it.title = accountName
appDatabase.getDao().getAccount(accountModel.uniqueId)?.also {
it.title = accountModel.title
it.password = accountModel.password
}
}
accountModel?.let { appDatabase.getDao().insertOrUpdateAccount(it) }
_goBack.postValue(Event(Unit))
accountModelDb?.let { appDatabase.getDao().insertOrUpdateAccount(it) }
onComplete()
}
}
fun setSecretKey(secretKey: String) {
this.secretKey.value = secretKey
}
fun setAccountName(accountName: String) {
this.accountName.value = accountName
}
fun deleteAccount(accountId: String, onDeleted: () -> Unit) {
viewModelScope.launch {
appDatabase.getDao().deleteAccount(accountId)

View File

@@ -51,9 +51,9 @@ import com.yogeshpaliyal.common.constants.AccountType
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.actions.CopyToClipboard
import com.yogeshpaliyal.keypass.ui.redux.actions.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.states.AccountDetailState
import com.yogeshpaliyal.keypass.ui.redux.states.TotpDetailState
import kotlinx.coroutines.delay
import org.reduxkotlin.compose.rememberDispatcher
import kotlin.time.Duration.Companion.seconds
@@ -77,7 +77,7 @@ fun AccountsList(accounts: List<AccountModel>? = null) {
account,
onClick = {
if (it.type == AccountType.TOTP) {
dispatch(IntentNavigation.AddTOTP(it.uniqueId))
dispatch(NavigationAction(TotpDetailState(it.uniqueId)))
} else {
dispatch(NavigationAction(AccountDetailState(it.id)))
}

View File

@@ -49,6 +49,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import com.yogeshpaliyal.keypass.BuildConfig
import com.yogeshpaliyal.keypass.ui.addTOTP.TOTPScreen
import com.yogeshpaliyal.keypass.ui.auth.AuthScreen
import com.yogeshpaliyal.keypass.ui.backup.BackupScreen
import com.yogeshpaliyal.keypass.ui.changeDefaultPasswordLength.ChangeDefaultPasswordLengthScreen
@@ -171,6 +172,7 @@ fun CurrentPage() {
}
is TotpDetailState -> {
TOTPScreen(it.accountId)
}
}
}

View File

@@ -4,6 +4,7 @@ import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.actions.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.states.HomeState
import com.yogeshpaliyal.keypass.ui.redux.states.TotpDetailState
object NavigationModel {
@@ -31,7 +32,7 @@ object NavigationModel {
icon = R.drawable.ic_twotone_totp,
titleRes = R.string.add_totp,
checked = false,
action = IntentNavigation.AddTOTP()
action = NavigationAction(TotpDetailState())
)
)
}

View File

@@ -4,5 +4,4 @@ sealed interface IntentNavigation : Action {
object GeneratePassword : IntentNavigation
object BackupActivity : IntentNavigation
object ShareApp : IntentNavigation
data class AddTOTP(val accountId: String? = null) : IntentNavigation
}

View File

@@ -3,7 +3,6 @@ package com.yogeshpaliyal.keypass.ui.redux.middlewares
import android.content.Intent
import com.yogeshpaliyal.keypass.BuildConfig
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.addTOTP.AddTOTPActivity
import com.yogeshpaliyal.keypass.ui.generate.GeneratePasswordActivity
import com.yogeshpaliyal.keypass.ui.redux.actions.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.states.KeyPassState
@@ -18,10 +17,6 @@ val intentNavigationMiddleware = middleware<KeyPassState> { store, next, action
state.context?.startActivity(intent)
}
is IntentNavigation.AddTOTP -> {
AddTOTPActivity.start(state.context, action.accountId)
}
is IntentNavigation.BackupActivity -> {
// BackupActivity.start(state.context)
}

View File

@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="mViewModel"
type="com.yogeshpaliyal.keypass.ui.addTOTP.AddTOTPViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.addTOTP.AddTOTPActivity">
<com.google.android.material.appbar.MaterialToolbar
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:id="@+id/toolbar"
app:navigationIcon="@drawable/ic_baseline_arrow_back_ios_24"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:endIconDrawable="@drawable/ic_twotone_qr_code_scanner_24"
app:endIconMode="custom"
android:id="@+id/tilSecretKey"
android:layout_marginTop="@dimen/grid_3"
android:layout_marginHorizontal="@dimen/grid_2">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Secret Key"
android:text="@={mViewModel.secretKey}"
android:id="@+id/etSecretKey"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/tilSecretKey"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:endIconMode="custom"
android:id="@+id/tilAccountName"
android:layout_marginTop="@dimen/grid_2"
android:layout_marginHorizontal="@dimen/grid_2">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/account_name"
android:text="@={mViewModel.accountName}"
android:id="@+id/etAccount"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_margin="@dimen/grid_2"
android:text="@string/save"
android:id="@+id/btnSave"
app:icon="@drawable/ic_round_done_24"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -107,5 +107,6 @@
<string name="authentication_error">Authentication Error %s</string>
<string name="change_password_length">Change password length</string>
<string name="default_password_length">Default password length</string>
<string name="secret_key">Secret Key</string>
</resources>