Backup Screen Migration to Compose (#512)

This commit is contained in:
Yogesh Choudhary Paliyal
2023-05-07 11:54:49 +05:30
committed by GitHub
parent b3c94f1b04
commit 0e65479a85
93 changed files with 4815 additions and 4666 deletions

View File

@@ -1,14 +1,14 @@
package com.yogeshpaliyal.keypass
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
// A custom runner to set up the instrumented application class for tests.
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
package com.yogeshpaliyal.keypass
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
// A custom runner to set up the instrumented application class for tests.
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View File

@@ -1,22 +1,22 @@
package com.yogeshpaliyal.keypass
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.yogeshpaliyal.keypass", appContext.packageName)
}
}
package com.yogeshpaliyal.keypass
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.yogeshpaliyal.keypass", appContext.packageName)
}
}

View File

@@ -1,110 +1,106 @@
package com.yogeshpaliyal.keypass.ui.nav
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.keypass.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class DashboardActivityTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
var activityScenarioRule = createAndroidComposeRule<DashboardActivity>()
@Inject
lateinit var appDatabase: com.yogeshpaliyal.common.AppDatabase
@Before
fun setUp() {
hiltRule.inject()
appDatabase.clearAllTables()
}
private fun getDummyAccount(): AccountModel {
val accountModel = AccountModel()
accountModel.title = "Github ${System.currentTimeMillis()}"
accountModel.username = "yogeshpaliyal"
accountModel.password = "1234567890"
accountModel.tags = "social"
accountModel.site = "https://yogeshpaliyal.com"
accountModel.notes = "Testing Notes"
return accountModel
}
@Test
fun addAccountAndDetailAndDeleteTest() {
val accountModel = getDummyAccount()
addAccount(accountModel)
checkAccountDetail(accountModel)
deleteAccount(accountModel)
}
private fun addAccount(accountModel: AccountModel) {
// Navigate to add screen
onView(withId(R.id.btnAdd)).perform(click())
// Fill information on Detail Activity
activityScenarioRule.onNodeWithTag("accountName").performTextInput(accountModel.title ?: "")
// generate random password
activityScenarioRule.onNodeWithTag("username").performTextInput(accountModel.username ?: "")
activityScenarioRule.onNodeWithTag("password").performTextInput(accountModel.password ?: "")
activityScenarioRule.onNodeWithTag("tags").performTextInput(accountModel.tags ?: "")
activityScenarioRule.onNodeWithTag("website").performTextInput(accountModel.site ?: "")
activityScenarioRule.onNodeWithTag("notes").performTextInput(accountModel.notes ?: "")
activityScenarioRule.onNodeWithTag("save").performClick()
// is showing in listing
activityScenarioRule.onNodeWithText(accountModel.username ?: "").assertIsDisplayed()
}
private fun checkAccountDetail(accountModel: AccountModel) {
// Navigate to account detail
activityScenarioRule.onNodeWithText(accountModel.username ?: "").performClick()
// Fill information on Detail Activity
activityScenarioRule.onNodeWithTag("accountName").assertTextEquals(accountModel.title ?: "")
// generate random password
activityScenarioRule.onNodeWithTag("username").assertTextEquals(accountModel.username ?: "")
activityScenarioRule.onNodeWithTag("password").assertTextEquals(accountModel.password ?: "")
activityScenarioRule.onNodeWithTag("tags").assertTextEquals(accountModel.tags ?: "")
activityScenarioRule.onNodeWithTag("website").assertTextEquals(accountModel.site ?: "")
activityScenarioRule.onNodeWithTag("notes").assertTextEquals(accountModel.notes ?: "")
}
private fun deleteAccount(accountModel: AccountModel) {
// delete account
activityScenarioRule.onNodeWithTag("action_delete").performClick()
activityScenarioRule.onNodeWithTag("delete").performClick()
// is not showing in listing
activityScenarioRule.onNodeWithText(accountModel.username ?: "").assertDoesNotExist()
}
}
package com.yogeshpaliyal.keypass.ui.nav
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.yogeshpaliyal.common.data.AccountModel
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class DashboardActivityTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
var activityScenarioRule = createAndroidComposeRule<DashboardComposeActivity>()
@Inject
lateinit var appDatabase: com.yogeshpaliyal.common.AppDatabase
@Before
fun setUp() {
hiltRule.inject()
appDatabase.clearAllTables()
}
private fun getDummyAccount(): AccountModel {
val accountModel = AccountModel()
accountModel.title = "Github ${System.currentTimeMillis()}"
accountModel.username = "yogeshpaliyal"
accountModel.password = "1234567890"
accountModel.tags = "social"
accountModel.site = "https://yogeshpaliyal.com"
accountModel.notes = "Testing Notes"
return accountModel
}
@Test
fun addAccountAndDetailAndDeleteTest() {
val accountModel = getDummyAccount()
addAccount(accountModel)
checkAccountDetail(accountModel)
deleteAccount(accountModel)
}
private fun addAccount(accountModel: AccountModel) {
// Navigate to add screen
activityScenarioRule.onNodeWithTag("btnAdd").performClick()
// Fill information on Detail Activity
activityScenarioRule.onNodeWithTag("accountName").performTextInput(accountModel.title ?: "")
// generate random password
activityScenarioRule.onNodeWithTag("username").performTextInput(accountModel.username ?: "")
activityScenarioRule.onNodeWithTag("password").performTextInput(accountModel.password ?: "")
activityScenarioRule.onNodeWithTag("tags").performTextInput(accountModel.tags ?: "")
activityScenarioRule.onNodeWithTag("website").performTextInput(accountModel.site ?: "")
activityScenarioRule.onNodeWithTag("notes").performTextInput(accountModel.notes ?: "")
activityScenarioRule.onNodeWithTag("save").performClick()
// is showing in listing
activityScenarioRule.onNodeWithText(accountModel.username ?: "").assertIsDisplayed()
}
private fun checkAccountDetail(accountModel: AccountModel) {
// Navigate to account detail
activityScenarioRule.onNodeWithText(accountModel.username ?: "").performClick()
// Fill information on Detail Activity
activityScenarioRule.onNodeWithTag("accountName").assertTextEquals(accountModel.title ?: "")
// generate random password
activityScenarioRule.onNodeWithTag("username").assertTextEquals(accountModel.username ?: "")
activityScenarioRule.onNodeWithTag("password").assertTextEquals(accountModel.password ?: "")
activityScenarioRule.onNodeWithTag("tags").assertTextEquals(accountModel.tags ?: "")
activityScenarioRule.onNodeWithTag("website").assertTextEquals(accountModel.site ?: "")
activityScenarioRule.onNodeWithTag("notes").assertTextEquals(accountModel.notes ?: "")
}
private fun deleteAccount(accountModel: AccountModel) {
// delete account
activityScenarioRule.onNodeWithTag("action_delete").performClick()
activityScenarioRule.onNodeWithTag("delete").performClick()
// is not showing in listing
activityScenarioRule.onNodeWithText(accountModel.username ?: "").assertDoesNotExist()
}
}

View File

@@ -1,20 +1,20 @@
package com.yogeshpaliyal.keypass
import android.content.Intent
import com.yogeshpaliyal.common.CommonMyApplication
import com.yogeshpaliyal.keypass.ui.CrashActivity
import dagger.hilt.android.HiltAndroidApp
/*
* @author Yogesh Paliyal
* yogeshpaliyal.foss@gmail.com
* https://techpaliyal.com
* created on 22-01-2021 22:41
*/
@HiltAndroidApp
class MyApplication : CommonMyApplication() {
override fun getCrashActivityIntent(throwable: Throwable): Intent {
return CrashActivity.getIntent(this, throwable.stackTraceToString())
}
}
package com.yogeshpaliyal.keypass
import android.content.Intent
import com.yogeshpaliyal.common.CommonMyApplication
import com.yogeshpaliyal.keypass.ui.CrashActivity
import dagger.hilt.android.HiltAndroidApp
/*
* @author Yogesh Paliyal
* yogeshpaliyal.foss@gmail.com
* https://techpaliyal.com
* created on 22-01-2021 22:41
*/
@HiltAndroidApp
class MyApplication : CommonMyApplication() {
override fun getCrashActivityIntent(throwable: Throwable): Intent {
return CrashActivity.getIntent(this, throwable.stackTraceToString())
}
}

View File

@@ -1,47 +0,0 @@
package com.yogeshpaliyal.keypass.customViews
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Path
import android.graphics.RectF
import android.util.AttributeSet
import com.google.android.material.card.MaterialCardView
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.shape.ShapeAppearancePathProvider
import com.yogeshpaliyal.keypass.R
/**
* A Card view that clips the content of any shape, this should be done upstream in card,
* working around it for now.
*/
class MaskedCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = R.attr.materialCardViewStyle
) : MaterialCardView(context, attrs, defStyle) {
@SuppressLint("RestrictedApi")
private val pathProvider = ShapeAppearancePathProvider()
private val path: Path = Path()
private val shapeAppearance: ShapeAppearanceModel = ShapeAppearanceModel.builder(
context,
attrs,
defStyle,
R.style.Widget_MaterialComponents_CardView
).build()
private val rectF = RectF(0f, 0f, 0f, 0f)
override fun onDraw(canvas: Canvas) {
canvas.clipPath(path)
super.onDraw(canvas)
}
@SuppressLint("RestrictedApi")
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
rectF.right = w.toFloat()
rectF.bottom = h.toFloat()
pathProvider.calculatePath(shapeAppearance, 1f, rectF, path)
super.onSizeChanged(w, h, oldw, oldh)
}
}

View File

@@ -1,14 +0,0 @@
package com.yogeshpaliyal.keypass.listener
import android.view.View
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 09:00
*/
interface AccountsClickListener<T> {
fun onItemClick(view: View, model: T)
fun onCopyClicked(model: T)
}

View File

@@ -1,13 +0,0 @@
package com.yogeshpaliyal.keypass.listener
import android.view.View
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 09:00
*/
interface UniversalClickListener<T> {
fun onItemClick(view: View, model: T)
}

View File

@@ -1,65 +1,65 @@
package com.yogeshpaliyal.keypass.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.yogeshpaliyal.keypass.BuildConfig
import com.yogeshpaliyal.keypass.databinding.ActivityCrashBinding
import dagger.hilt.android.AndroidEntryPoint
import java.lang.StringBuilder
@AndroidEntryPoint
class CrashActivity : AppCompatActivity() {
private lateinit var binding: ActivityCrashBinding
companion object {
private const val ARG_DATA = "arg_data"
fun getIntent(context: Context, data: String?): Intent {
return Intent(context, CrashActivity::class.java).also {
it.putExtra(ARG_DATA, data)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCrashBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.txtCrash.text = intent.extras?.getString(ARG_DATA)
binding.btnSendFeedback.setOnClickListener {
val deviceInfo = StringBuilder()
deviceInfo.append(binding.txtCrash.text.toString())
try {
deviceInfo.append("\n")
deviceInfo.append("App Version: " + BuildConfig.VERSION_NAME)
deviceInfo.append("\n")
deviceInfo.append("Brand Name: " + Build.BRAND)
deviceInfo.append("\n")
deviceInfo.append("Manufacturer Name: " + Build.MANUFACTURER)
deviceInfo.append("\n")
deviceInfo.append("Device Name: " + Build.MODEL)
deviceInfo.append("\n")
deviceInfo.append("Device API Version: " + Build.VERSION.SDK_INT)
deviceInfo.append("\n")
} catch (e: Exception) {
e.printStackTrace()
}
val intent = Intent(Intent.ACTION_SENDTO)
intent.data = Uri.parse("mailto:")
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("yogeshpaliyal.foss@gmail.com"))
intent.putExtra(Intent.EXTRA_SUBJECT, "Crash Report in KeyPass")
intent.putExtra(Intent.EXTRA_TEXT, deviceInfo.toString())
startActivity(Intent.createChooser(intent, ""))
}
}
}
package com.yogeshpaliyal.keypass.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.yogeshpaliyal.keypass.BuildConfig
import com.yogeshpaliyal.keypass.databinding.ActivityCrashBinding
import dagger.hilt.android.AndroidEntryPoint
import java.lang.StringBuilder
@AndroidEntryPoint
class CrashActivity : AppCompatActivity() {
private lateinit var binding: ActivityCrashBinding
companion object {
private const val ARG_DATA = "arg_data"
fun getIntent(context: Context, data: String?): Intent {
return Intent(context, CrashActivity::class.java).also {
it.putExtra(ARG_DATA, data)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCrashBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.txtCrash.text = intent.extras?.getString(ARG_DATA)
binding.btnSendFeedback.setOnClickListener {
val deviceInfo = StringBuilder()
deviceInfo.append(binding.txtCrash.text.toString())
try {
deviceInfo.append("\n")
deviceInfo.append("App Version: " + BuildConfig.VERSION_NAME)
deviceInfo.append("\n")
deviceInfo.append("Brand Name: " + Build.BRAND)
deviceInfo.append("\n")
deviceInfo.append("Manufacturer Name: " + Build.MANUFACTURER)
deviceInfo.append("\n")
deviceInfo.append("Device Name: " + Build.MODEL)
deviceInfo.append("\n")
deviceInfo.append("Device API Version: " + Build.VERSION.SDK_INT)
deviceInfo.append("\n")
} catch (e: Exception) {
e.printStackTrace()
}
val intent = Intent(Intent.ACTION_SENDTO)
intent.data = Uri.parse("mailto:")
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("yogeshpaliyal.foss@gmail.com"))
intent.putExtra(Intent.EXTRA_SUBJECT, "Crash Report in KeyPass")
intent.putExtra(Intent.EXTRA_TEXT, deviceInfo.toString())
startActivity(Intent.createChooser(intent, ""))
}
}
}

View File

@@ -1,140 +1,140 @@
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 com.yogeshpaliyal.common.utils.TOTPHelper
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.databinding.ActivityAddTotpactivityBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class AddTOTPActivity : AppCompatActivity() {
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()
}
}
)
mViewModel.goBack.observe(
this,
Observer {
it.getContentIfNotHandled()?.let {
onBackPressed()
}
}
)
binding.btnSave.setOnClickListener {
mViewModel.saveAccount(accountId)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
if (accountId != null) {
menuInflater.inflate(R.menu.menu_delete, menu)
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_delete) {
deleteAccount()
}
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()
}
}
}
.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)
mViewModel.setSecretKey(totp.secret)
mViewModel.setAccountName(totp.label)
} catch (e: Exception) {
e.printStackTrace()
}
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
}
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 com.yogeshpaliyal.common.utils.TOTPHelper
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.databinding.ActivityAddTotpactivityBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class AddTOTPActivity : AppCompatActivity() {
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()
}
}
)
mViewModel.goBack.observe(
this,
Observer {
it.getContentIfNotHandled()?.let {
onBackPressed()
}
}
)
binding.btnSave.setOnClickListener {
mViewModel.saveAccount(accountId)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
if (accountId != null) {
menuInflater.inflate(R.menu.menu_delete, menu)
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_delete) {
deleteAccount()
}
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()
}
}
}
.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)
mViewModel.setSecretKey(totp.secret)
mViewModel.setAccountName(totp.label)
} catch (e: Exception) {
e.printStackTrace()
}
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
}

View File

@@ -1,85 +1,85 @@
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
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?) {
accountId ?: return
viewModelScope.launch(Dispatchers.IO) {
appDatabase.getDao().getAccount(accountId)?.let { accountModel ->
accountName.postValue(accountModel.title ?: "")
}
}
}
fun saveAccount(accountId: String?) {
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)
} else {
appDatabase.getDao().getAccount(accountId)?.also {
it.title = accountName
}
}
accountModel?.let { appDatabase.getDao().insertOrUpdateAccount(it) }
_goBack.postValue(Event(Unit))
}
}
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)
onDeleted()
}
}
}
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
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?) {
accountId ?: return
viewModelScope.launch(Dispatchers.IO) {
appDatabase.getDao().getAccount(accountId)?.let { accountModel ->
accountName.postValue(accountModel.title ?: "")
}
}
}
fun saveAccount(accountId: String?) {
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)
} else {
appDatabase.getDao().getAccount(accountId)?.also {
it.title = accountName
}
}
accountModel?.let { appDatabase.getDao().insertOrUpdateAccount(it) }
_goBack.postValue(Event(Unit))
}
}
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)
onDeleted()
}
}
}

View File

@@ -1,55 +1,55 @@
package com.yogeshpaliyal.keypass.ui.addTOTP
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.yogeshpaliyal.common.constants.RequestCodes
import com.yogeshpaliyal.keypass.databinding.ActivityScannerBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ScannerActivity : AppCompatActivity() {
private lateinit var binding: ActivityScannerBinding
private val REQUEST_CAM_PERMISSION = 432
companion object {
@JvmStatic
fun start(activity: Activity) {
val starter = Intent(activity, ScannerActivity::class.java)
activity.startActivityForResult(starter, RequestCodes.SCANNER)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener {
onBackPressed()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CAM_PERMISSION) {
if (isAllRequestGranted(grantResults)) {
// codeScanner?.startPreview()
}
}
}
private fun isAllRequestGranted(grantResults: IntArray) =
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
}
package com.yogeshpaliyal.keypass.ui.addTOTP
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.yogeshpaliyal.common.constants.RequestCodes
import com.yogeshpaliyal.keypass.databinding.ActivityScannerBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ScannerActivity : AppCompatActivity() {
private lateinit var binding: ActivityScannerBinding
private val REQUEST_CAM_PERMISSION = 432
companion object {
@JvmStatic
fun start(activity: Activity) {
val starter = Intent(activity, ScannerActivity::class.java)
activity.startActivityForResult(starter, RequestCodes.SCANNER)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener {
onBackPressed()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CAM_PERMISSION) {
if (isAllRequestGranted(grantResults)) {
// codeScanner?.startPreview()
}
}
}
private fun isAllRequestGranted(grantResults: IntArray) =
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
}

View File

@@ -1,90 +1,90 @@
package com.yogeshpaliyal.keypass.ui.auth
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
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.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.unit.dp
import com.yogeshpaliyal.common.utils.getKeyPassPassword
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.auth.components.ButtonBar
import com.yogeshpaliyal.keypass.ui.auth.components.PasswordInputField
import com.yogeshpaliyal.keypass.ui.redux.AuthState
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
import kotlinx.coroutines.launch
import org.reduxkotlin.compose.rememberDispatcher
@Composable
fun AuthScreen(state: AuthState) {
val context = LocalContext.current
val dispatchAction = rememberDispatcher()
val coroutineScope = rememberCoroutineScope()
val (password, setPassword) = remember(state) {
mutableStateOf("")
}
val (passwordVisible, setPasswordVisible) = remember(state) { mutableStateOf(false) }
val (passwordError, setPasswordError) = remember(state, password) {
mutableStateOf<Int?>(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)
}
}
}
package com.yogeshpaliyal.keypass.ui.auth
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
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.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.unit.dp
import com.yogeshpaliyal.common.utils.getKeyPassPassword
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.auth.components.ButtonBar
import com.yogeshpaliyal.keypass.ui.auth.components.PasswordInputField
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.states.AuthState
import kotlinx.coroutines.launch
import org.reduxkotlin.compose.rememberDispatcher
@Composable
fun AuthScreen(state: AuthState) {
val context = LocalContext.current
val dispatchAction = rememberDispatcher()
val coroutineScope = rememberCoroutineScope()
val (password, setPassword) = remember(state) {
mutableStateOf("")
}
val (passwordVisible, setPasswordVisible) = remember(state) { mutableStateOf(false) }
val (passwordError, setPasswordError) = remember(state, password) {
mutableStateOf<Int?>(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)
}
}
}

View File

@@ -1,77 +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))
}
}
}
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.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.states.AuthState
import com.yogeshpaliyal.keypass.ui.redux.states.HomeState
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))
}
}
}

View File

@@ -1,62 +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)
}
}
)
}
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)
}
}
)
}

View File

@@ -1,330 +1,251 @@
package com.yogeshpaliyal.keypass.ui.backup
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.yogeshpaliyal.common.utils.BACKUP_KEY_LENGTH
import com.yogeshpaliyal.common.utils.backupAccounts
import com.yogeshpaliyal.common.utils.canUserAccessBackupDirectory
import com.yogeshpaliyal.common.utils.clearBackupKey
import com.yogeshpaliyal.common.utils.formatCalendar
import com.yogeshpaliyal.common.utils.getBackupDirectory
import com.yogeshpaliyal.common.utils.getBackupTime
import com.yogeshpaliyal.common.utils.getOrCreateBackupKey
import com.yogeshpaliyal.common.utils.isAutoBackupEnabled
import com.yogeshpaliyal.common.utils.isKeyPresent
import com.yogeshpaliyal.common.utils.overrideAutoBackup
import com.yogeshpaliyal.common.utils.saveKeyphrase
import com.yogeshpaliyal.common.utils.setAutoBackupEnabled
import com.yogeshpaliyal.common.utils.setBackupDirectory
import com.yogeshpaliyal.common.utils.setBackupTime
import com.yogeshpaliyal.common.utils.setOverrideAutoBackup
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.databinding.BackupActivityBinding
import com.yogeshpaliyal.keypass.databinding.LayoutBackupKeypharseBinding
import com.yogeshpaliyal.keypass.databinding.LayoutCustomKeypharseBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLDecoder
import javax.inject.Inject
@AndroidEntryPoint
class BackupActivity : AppCompatActivity() {
companion object {
@JvmStatic
fun start(context: Context?) {
val starter = Intent(context, BackupActivity::class.java)
context?.startActivity(starter)
}
}
private lateinit var binding: BackupActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = BackupActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener {
onBackPressed()
}
if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, SettingsFragment())
.commit()
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
@AndroidEntryPoint
class SettingsFragment : PreferenceFragmentCompat() {
@Inject
lateinit var appDb: com.yogeshpaliyal.common.AppDatabase
private val CHOOSE_BACKUPS_LOCATION_REQUEST_CODE = 26212
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.backup_preferences, rootKey)
updateItems()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
getString(R.string.settings_start_backup) -> {
startBackup()
}
getString(R.string.settings_create_backup) -> {
lifecycleScope.launch {
if (context.canUserAccessBackupDirectory()) {
val selectedDirectory = Uri.parse(context.getBackupDirectory())
passwordSelection(selectedDirectory)
}
}
}
getString(R.string.settings_backup_folder) -> {
changeBackupFolder()
}
getString(R.string.settings_verify_key_phrase) -> {
verifyKeyPhrase()
}
getString(R.string.settings_stop_backup) -> {
lifecycleScope.launch {
stopBackup()
}
}
getString(R.string.settings_auto_backup) -> {
lifecycleScope.launch {
context.setAutoBackupEnabled(context.isAutoBackupEnabled().not())
updateItems()
}
}
getString(R.string.settings_override_auto_backup) -> {
lifecycleScope.launch {
context.setOverrideAutoBackup(context.overrideAutoBackup().not())
updateItems()
}
}
}
return super.onPreferenceTreeClick(preference)
}
private suspend fun passwordSelection(selectedDirectory: Uri) {
val isKeyPresent = context?.isKeyPresent() ?: return
if (isKeyPresent) {
backup(selectedDirectory)
return
}
val builder = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.alert)
.setMessage(getString(R.string.custom_generated_keyphrase_info))
.setPositiveButton(
getString(R.string.custom_keyphrase)
) { dialog, which ->
dialog?.dismiss()
setCustomKeyphrase(selectedDirectory)
}
.setNegativeButton(R.string.generate_keyphrase) { dialog, which ->
dialog?.dismiss()
backup(selectedDirectory)
}
builder.show()
}
private fun setCustomKeyphrase(selectedDirectory: Uri) {
val binding = LayoutCustomKeypharseBinding.inflate(layoutInflater)
val dialog = MaterialAlertDialogBuilder(requireContext()).setView(binding.root)
.setPositiveButton(
getString(R.string.yes)
) { dialog, which ->
dialog?.dismiss()
}.create()
dialog.setOnShowListener {
val positiveBtn = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
positiveBtn.setOnClickListener {
val keyphrase = binding.etKeyPhrase.text.toString().trim()
if (keyphrase.isEmpty()) {
Toast.makeText(context, R.string.alert_blank_keyphrase, Toast.LENGTH_SHORT)
.show()
return@setOnClickListener
}
if (keyphrase.length != BACKUP_KEY_LENGTH) {
Toast.makeText(
context,
R.string.alert_invalid_keyphrase,
Toast.LENGTH_SHORT
).show()
return@setOnClickListener
}
lifecycleScope.launch {
context?.saveKeyphrase(keyphrase)
backup(selectedDirectory)
}
dialog.dismiss()
}
}
dialog.show()
}
fun backup(selectedDirectory: Uri) {
lifecycleScope.launch {
context.backupAccounts(appDb, selectedDirectory)?.let { keyPair ->
if (keyPair.first) {
val binding = LayoutBackupKeypharseBinding.inflate(layoutInflater)
binding.txtCode.text = context?.getOrCreateBackupKey()?.second ?: ""
binding.txtCode.setOnClickListener {
val clipboard =
ContextCompat.getSystemService(
requireContext(),
ClipboardManager::class.java
)
val clip = ClipData.newPlainText("KeyPass", 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(
getString(R.string.yes)
) { dialog, which ->
updateItems()
dialog?.dismiss()
}.show()
} else {
updateItems()
Toast.makeText(
context,
getString(R.string.backup_completed),
Toast.LENGTH_SHORT
).show()
}
}
}
}
private fun updateItems() {
lifecycleScope.launch(Dispatchers.IO) {
val isBackupEnabled =
context.canUserAccessBackupDirectory() && (context?.isKeyPresent() ?: false)
val isAutoBackupEnabled = context.isAutoBackupEnabled()
val overrideAutoBackup = context.overrideAutoBackup()
val lastBackupTime = context.getBackupTime()
val backupDirectory = context.getBackupDirectory()
withContext(Dispatchers.Main) {
findPreference<Preference>(getString(R.string.settings_start_backup))?.isVisible =
isBackupEnabled.not()
findPreference<Preference>(getString(R.string.settings_stop_backup))?.isVisible =
isBackupEnabled
findPreference<Preference>(getString(R.string.settings_auto_backup))?.isVisible =
isBackupEnabled
findPreference<Preference>(getString(R.string.settings_auto_backup))?.summary =
if (isAutoBackupEnabled) getString(R.string.enabled) else getString(R.string.disabled)
findPreference<PreferenceCategory>(getString(R.string.settings_cat_auto_backup))?.isVisible =
isBackupEnabled && isAutoBackupEnabled
findPreference<Preference>(getString(R.string.settings_override_auto_backup))?.summary =
if (overrideAutoBackup) getString(R.string.enabled) else getString(R.string.disabled)
findPreference<Preference>(getString(R.string.settings_create_backup))?.isVisible =
isBackupEnabled
findPreference<Preference>(getString(R.string.settings_create_backup))?.summary =
getString(
R.string.last_backup_date,
lastBackupTime.formatCalendar("dd MMM yyyy hh:mm aa")
)
findPreference<Preference>(getString(R.string.settings_backup_folder))?.isVisible =
isBackupEnabled
val directory = URLDecoder.decode(backupDirectory, "utf-8").split("/")
val folderName = directory.get(directory.lastIndex)
findPreference<Preference>(getString(R.string.settings_backup_folder))?.summary =
folderName
findPreference<Preference>(getString(R.string.settings_verify_key_phrase))?.isVisible =
false
findPreference<Preference>(getString(R.string.settings_backup))?.isVisible =
isBackupEnabled
}
}
}
private fun startBackup() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
startActivityForResult(intent, CHOOSE_BACKUPS_LOCATION_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())
passwordSelection(selectedDirectory)
}
}
}
}
private fun changeBackupFolder() {
startBackup()
}
private fun verifyKeyPhrase() {
Toast.makeText(context, getString(R.string.coming_soon), Toast.LENGTH_SHORT).show()
}
private suspend fun stopBackup() {
context.clearBackupKey()
context.setBackupDirectory("")
context.setBackupTime(-1)
context.setOverrideAutoBackup(false)
context.setAutoBackupEnabled(false)
updateItems()
}
}
}
package com.yogeshpaliyal.keypass.ui.backup
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBackIosNew
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Divider
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.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.yogeshpaliyal.common.utils.canUserAccessBackupDirectory
import com.yogeshpaliyal.common.utils.clearBackupKey
import com.yogeshpaliyal.common.utils.formatCalendar
import com.yogeshpaliyal.common.utils.getBackupDirectory
import com.yogeshpaliyal.common.utils.getBackupTime
import com.yogeshpaliyal.common.utils.isAutoBackupEnabled
import com.yogeshpaliyal.common.utils.isKeyPresent
import com.yogeshpaliyal.common.utils.overrideAutoBackup
import com.yogeshpaliyal.common.utils.setAutoBackupEnabled
import com.yogeshpaliyal.common.utils.setBackupDirectory
import com.yogeshpaliyal.common.utils.setOverrideAutoBackup
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.backup.components.BackupDialogs
import com.yogeshpaliyal.keypass.ui.redux.actions.Action
import com.yogeshpaliyal.keypass.ui.redux.actions.GoBackAction
import com.yogeshpaliyal.keypass.ui.redux.actions.StateUpdateAction
import com.yogeshpaliyal.keypass.ui.redux.states.BackupScreenState
import com.yogeshpaliyal.keypass.ui.redux.states.SelectKeyphraseType
import com.yogeshpaliyal.keypass.ui.redux.states.ShowKeyphrase
import com.yogeshpaliyal.keypass.ui.settings.PreferenceItem
import kotlinx.coroutines.launch
import org.reduxkotlin.compose.rememberTypedDispatcher
@Composable
fun BackupScreen(state: BackupScreenState) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val dispatchAction = rememberTypedDispatcher<Action>()
val launcher = rememberLauncherForActivityResult(KeyPassBackupDirectoryPick()) {
if (it == null) {
return@rememberLauncherForActivityResult
}
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
coroutineScope.launch {
val dialog = if (context.isKeyPresent()) {
ShowKeyphrase
} else {
SelectKeyphraseType
}
dispatchAction(
StateUpdateAction(
state.copy(
backupDirectory = it,
dialog = dialog
)
)
)
}
}
LaunchedEffect(key1 = state, block = {
state.backupDirectory?.let {
context.setBackupDirectory(it.toString())
}
state.isAutoBackupEnabled?.let {
context.setAutoBackupEnabled(it)
}
state.overrideAutoBackup?.let {
context.setOverrideAutoBackup(it)
}
})
LaunchedEffect(key1 = state.isBackupEnabled, block = {
state.isBackupEnabled?.let {
if (it.not()) {
context.clearBackupKey()
}
}
})
LaunchedEffect(key1 = Unit, block = {
val isBackupEnabled = (
context.canUserAccessBackupDirectory() && (context.isKeyPresent())
)
val isAutoBackupEnabled = context.isAutoBackupEnabled()
val overrideAutoBackup = context.overrideAutoBackup()
val lastBackupTime = context.getBackupTime()
val backupDirectory = context.getBackupDirectory()
dispatchAction(
StateUpdateAction(
state.copy(
isBackupEnabled = isBackupEnabled,
isAutoBackupEnabled = isAutoBackupEnabled,
overrideAutoBackup = overrideAutoBackup,
lastBackupTime = lastBackupTime,
backupDirectory = Uri.parse(backupDirectory)
)
)
)
})
Scaffold(bottomBar = {
BottomAppBar {
IconButton(onClick = {
dispatchAction(GoBackAction)
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.ArrowBackIosNew),
contentDescription = "Go Back",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}) { contentPadding ->
Surface(modifier = Modifier.padding(contentPadding)) {
BackSettingOptions(state, updatedState = {
dispatchAction(StateUpdateAction(it))
}) {
launcher.launch(arrayOf())
}
}
}
BackupDialogs(state = state) {
dispatchAction(StateUpdateAction(it))
}
}
@Composable
fun BackSettingOptions(
state: BackupScreenState,
updatedState: (BackupScreenState) -> Unit,
launchDirectorySelector: () -> Unit
) {
Column {
PreferenceItem(summary = R.string.backup_desc)
AnimatedVisibility(visible = state.isBackupEnabled == true) {
BackupEnableOptions(state, updatedState, launchDirectorySelector)
}
AnimatedVisibility(visible = state.isBackupEnabled != true) {
PreferenceItem(title = R.string.turn_on_backup, onClickItem = launchDirectorySelector)
}
}
}
@Composable
fun BackupEnableOptions(
state: BackupScreenState,
updatedState: (BackupScreenState) -> Unit,
launchDirectorySelector: () -> Unit
) {
Column {
PreferenceItem(
title = R.string.create_backup,
summaryStr = stringResource(
id = R.string.last_backup_date,
state.lastBackupTime?.formatCalendar("dd MMM yyyy hh:mm aa") ?: ""
)
) {
updatedState(state.copy(dialog = ShowKeyphrase))
}
PreferenceItem(
title = R.string.backup_folder,
summaryStr = state.getFormattedBackupDirectory(),
onClickItem = launchDirectorySelector
)
AutoBackup(state.isAutoBackupEnabled, state.overrideAutoBackup, {
updatedState(state.copy(isAutoBackupEnabled = it))
}) {
updatedState(state.copy(overrideAutoBackup = it))
}
// PreferenceItem(
// title = R.string.verify_keyphrase,
// summary = R.string.verify_keyphrase_message
// )
PreferenceItem(title = R.string.turn_off_backup) {
updatedState(
state.copy(
isBackupEnabled = false,
isAutoBackupEnabled = false,
overrideAutoBackup = false,
lastBackupTime = -1,
backupDirectory = null
)
)
}
}
}
@Composable
fun AutoBackup(
isAutoBackupEnabled: Boolean?,
overrideAutoBackup: Boolean?,
setAutoBackupEnabled: (Boolean) -> Unit,
setOverrideAutoBackup: (Boolean) -> Unit
) {
PreferenceItem(
title = R.string.auto_backup,
summary = if (isAutoBackupEnabled == true) R.string.enabled else R.string.disabled
) {
setAutoBackupEnabled(!(isAutoBackupEnabled ?: false))
}
AnimatedVisibility(visible = isAutoBackupEnabled == true) {
Column {
PreferenceItem(title = R.string.auto_backup, isCategory = true)
Divider(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
)
PreferenceItem(summary = R.string.auto_backup_desc)
PreferenceItem(
title = R.string.override_auto_backup_file,
summary = if (overrideAutoBackup == true) R.string.enabled else R.string.disabled
) {
setOverrideAutoBackup(!(overrideAutoBackup ?: false))
}
}
}
}

View File

@@ -0,0 +1,19 @@
package com.yogeshpaliyal.keypass.ui.backup
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
class KeyPassBackupDirectoryPick : ActivityResultContracts.OpenDocument() {
override fun createIntent(context: Context, input: Array<String>): Intent {
super.createIntent(context, input)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
return intent
}
}

View File

@@ -0,0 +1,47 @@
package com.yogeshpaliyal.keypass.ui.backup.components
import androidx.compose.runtime.Composable
import com.yogeshpaliyal.keypass.ui.redux.states.BackupScreenState
import com.yogeshpaliyal.keypass.ui.redux.states.CustomKeyphrase
import com.yogeshpaliyal.keypass.ui.redux.states.SelectKeyphraseType
import com.yogeshpaliyal.keypass.ui.redux.states.ShowKeyphrase
@Composable
fun BackupDialogs(state: BackupScreenState, updateState: (BackupScreenState) -> Unit) {
when (state.dialog) {
is SelectKeyphraseType -> {
SelectKeyphraseType(customKeyphrase = {
updateState(state.copy(dialog = CustomKeyphrase))
}, generateKeyphrase = {
updateState(state.copy(dialog = ShowKeyphrase))
}) {
updateState(state.copy(dialog = null))
}
}
is CustomKeyphrase -> {
CreateCustomKeyphrase(saveKeyphrase = {
updateState(state.copy(dialog = ShowKeyphrase))
}) {
updateState(state.copy(dialog = null))
}
}
is ShowKeyphrase -> {
ShowKeyPhraseDialog(selectedDirectory = state.backupDirectory, onYesClicked = {
updateState(state.copy(dialog = null, isBackupEnabled = true))
}) {
updateState(
state.copy(
isBackupEnabled = true,
lastBackupTime = System.currentTimeMillis()
)
)
}
}
null -> {
// Do Noting Do not show dialog
}
}
}

View File

@@ -0,0 +1,75 @@
package com.yogeshpaliyal.keypass.ui.backup.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.yogeshpaliyal.common.utils.BACKUP_KEY_LENGTH
import com.yogeshpaliyal.common.utils.saveKeyphrase
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.actions.Action
import com.yogeshpaliyal.keypass.ui.redux.actions.ToastAction
import kotlinx.coroutines.launch
import org.reduxkotlin.compose.rememberTypedDispatcher
@Composable
fun CreateCustomKeyphrase(saveKeyphrase: () -> Unit, dismissDialog: () -> Unit) {
val (keyphrase, setKeyPhrase) = remember {
mutableStateOf("")
}
val dispatchAction = rememberTypedDispatcher<Action>()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
AlertDialog(onDismissRequest = dismissDialog, title = {
Text(text = stringResource(id = R.string.set_keyphrase))
}, 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 {
context.saveKeyphrase(keyphrase)
}
saveKeyphrase.invoke()
}) {
Text(stringResource(id = R.string.yes))
}
}, text = {
Column {
Text(text = stringResource(id = R.string.set_keyphrase_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))
}
)
}
})
}

View File

@@ -0,0 +1,32 @@
package com.yogeshpaliyal.keypass.ui.backup.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.yogeshpaliyal.keypass.R
@Composable
fun SelectKeyphraseType(
customKeyphrase: () -> Unit,
generateKeyphrase: () -> Unit,
dismissDialog: () -> Unit
) {
AlertDialog(onDismissRequest = dismissDialog, title = {
Text(text = stringResource(id = R.string.alert))
}, confirmButton = {
TextButton(onClick = customKeyphrase) {
Text(stringResource(id = R.string.custom_keyphrase))
}
}, dismissButton = {
TextButton(onClick = generateKeyphrase) {
Text(stringResource(id = R.string.generate_keyphrase))
}
}, text = {
Column {
Text(text = stringResource(id = R.string.custom_generated_keyphrase_info))
}
})
}

View File

@@ -0,0 +1,75 @@
package com.yogeshpaliyal.keypass.ui.backup.components
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.yogeshpaliyal.common.utils.backupAccounts
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.actions.Action
import com.yogeshpaliyal.keypass.ui.redux.actions.CopyToClipboard
import com.yogeshpaliyal.keypass.ui.redux.actions.ToastAction
import com.yogeshpaliyal.keypass.ui.settings.MySettingsViewModel
import org.reduxkotlin.compose.rememberTypedDispatcher
@Composable
fun ShowKeyPhraseDialog(
selectedDirectory: Uri?,
mySettingsViewModel: MySettingsViewModel = hiltViewModel(),
onYesClicked: () -> Unit,
saveKeyphrase: () -> Unit
) {
if (selectedDirectory == null) {
return
}
val dispatchAction = rememberTypedDispatcher<Action>()
val context = LocalContext.current
val (backupInfo, setBackupInfo) = remember {
mutableStateOf<Pair<Boolean, String>?>(null)
}
LaunchedEffect(key1 = selectedDirectory, block = {
val localBackupInfo = context.backupAccounts(
mySettingsViewModel.appDb,
selectedDirectory
)
setBackupInfo(localBackupInfo)
saveKeyphrase()
})
if (backupInfo != null) {
val newKeyCreated = backupInfo.first
if (newKeyCreated) {
AlertDialog(onDismissRequest = {}, title = {
Text(text = stringResource(id = R.string.alert))
}, confirmButton = {
TextButton(onClick = {
onYesClicked()
}) {
Text(stringResource(id = R.string.yes))
}
}, text = {
Column {
Text(text = stringResource(id = R.string.copy_keypharse_msg))
TextButton(onClick = {
dispatchAction(CopyToClipboard(backupInfo.second))
}) {
Text(text = backupInfo.second)
}
}
})
} else {
dispatchAction(ToastAction(R.string.backup_completed))
}
}
}

View File

@@ -1,356 +1,356 @@
package com.yogeshpaliyal.keypass.ui.detail
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.annotation.StringRes
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.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.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.common.utils.PasswordGenerator
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.GoBackAction
import org.reduxkotlin.compose.rememberDispatcher
/*
* @author Yogesh Paliyal
* yogeshpaliyal.foss@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 10:38
*/
@Composable
fun AccountDetailPage(
id: Long?,
viewModel: DetailViewModel = hiltViewModel()
) {
val dispatchAction = rememberDispatcher()
// task value state
val (accountModel, setAccountModel) = remember {
mutableStateOf(
AccountModel()
)
}
// Set initial object
LaunchedEffect(key1 = id) {
viewModel.loadAccount(id) {
setAccountModel(it.copy())
}
}
val goBack: () -> Unit = {
dispatchAction(GoBackAction)
}
val launcher = rememberLauncherForActivityResult(QRScanner()) {
it?.let {
setAccountModel(accountModel.copy(password = it))
}
}
Scaffold(
bottomBar = {
BottomBar(
accountModel,
backPressed = goBack,
onDeleteAccount = {
viewModel.deleteAccount(accountModel, goBack)
}
) {
viewModel.insertOrUpdate(accountModel, goBack)
}
}
) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
Fields(
accountModel = accountModel,
updateAccountModel = { newAccountModel ->
setAccountModel(newAccountModel)
}
) {
launcher.launch(null)
}
}
}
}
@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)
) {
KeyPassInputField(
modifier = Modifier.testTag("accountName"),
placeholder = R.string.account_name,
value = accountModel.title,
setValue = {
updateAccountModel(accountModel.copy(title = it))
}
)
KeyPassInputField(
modifier = Modifier.testTag("username"),
placeholder = R.string.username_email_phone,
value = accountModel.username,
setValue = {
updateAccountModel(accountModel.copy(username = it))
}
)
Column {
val passwordVisible = rememberSaveable { mutableStateOf(false) }
val visualTransformation =
if (passwordVisible.value) VisualTransformation.None else PasswordVisualTransformation()
KeyPassInputField(
modifier = Modifier.testTag("password"),
placeholder = R.string.password,
value = accountModel.password,
setValue = {
updateAccountModel(accountModel.copy(password = it))
},
trailingIcon = {
PasswordTrailingIcon(passwordVisible.value) {
passwordVisible.value = it
}
},
leadingIcon = if (accountModel.id != null) {
null
} else (
{
IconButton(
onClick = {
updateAccountModel(accountModel.copy(password = PasswordGenerator().generatePassword()))
}
) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Refresh),
contentDescription = ""
)
}
}
),
visualTransformation = visualTransformation
)
Button(onClick = scanClicked) {
Row {
Icon(
painter = painterResource(id = R.drawable.ic_twotone_qr_code_scanner_24),
contentDescription = ""
)
Text(text = stringResource(id = R.string.scan_password))
}
}
}
KeyPassInputField(
modifier = Modifier.testTag("tags"),
placeholder = R.string.tags_comma_separated_optional,
value = accountModel.tags,
setValue = {
updateAccountModel(accountModel.copy(tags = it))
}
)
KeyPassInputField(
modifier = Modifier.testTag("website"),
placeholder = R.string.website_url_optional,
value = accountModel.site,
setValue = {
updateAccountModel(accountModel.copy(site = it))
}
)
KeyPassInputField(
modifier = Modifier.testTag("notes"),
placeholder = R.string.notes_optional,
value = accountModel.notes,
setValue = {
updateAccountModel(accountModel.copy(notes = it))
}
)
}
}
@Composable
fun DeleteConfirmation(
openDialog: Boolean,
updateDialogVisibility: (Boolean) -> Unit,
onDelete: () -> Unit
) {
if (openDialog) {
AlertDialog(
onDismissRequest = { /*TODO*/ },
title = {
Text(text = stringResource(id = R.string.delete_account_title))
},
confirmButton = {
TextButton(
modifier = Modifier.testTag("delete"),
onClick = {
updateDialogVisibility(false)
onDelete()
}
) {
Text(text = stringResource(id = R.string.delete))
}
},
text = {
Text(text = stringResource(id = R.string.delete_account_msg))
},
dismissButton = {
TextButton(
onClick = {
updateDialogVisibility(false)
}
) {
Text(text = stringResource(id = R.string.cancel))
}
}
)
}
}
@Composable
fun KeyPassInputField(
modifier: Modifier = Modifier,
@StringRes placeholder: Int,
value: String?,
setValue: (String) -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
OutlinedTextField(
modifier = modifier.fillMaxWidth(),
value = value ?: "",
label = {
Text(text = stringResource(id = placeholder))
},
onValueChange = setValue,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
visualTransformation = visualTransformation
)
}
@Composable
fun PasswordTrailingIcon(
passwordVisible: Boolean,
changePasswordVisibility: (updatedValue: Boolean) -> Unit
) {
val description = if (passwordVisible) "Hide password" else "Show password"
val image = if (passwordVisible) {
Icons.Rounded.Visibility
} else {
Icons.Rounded.VisibilityOff
}
IconButton(onClick = { changePasswordVisibility(!passwordVisible) }) {
Icon(
painter = rememberVectorPainter(image = image),
contentDescription = description
)
}
}
@Composable
fun BottomBar(
accountModel: AccountModel,
backPressed: () -> Unit,
onDeleteAccount: () -> Unit,
onSaveClicked: () -> Unit
) {
val openDialog = remember { mutableStateOf(false) }
BottomAppBar(
actions = {
IconButton(onClick = backPressed) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.ArrowBackIosNew),
contentDescription = "Go Back",
tint = MaterialTheme.colorScheme.onSurface
)
}
if (accountModel.id != null) {
IconButton(
modifier = Modifier.testTag("action_delete"),
onClick = { openDialog.value = 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.value,
updateDialogVisibility = {
openDialog.value = it
},
onDeleteAccount
)
}
package com.yogeshpaliyal.keypass.ui.detail
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.annotation.StringRes
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.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.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.common.utils.PasswordGenerator
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.actions.GoBackAction
import org.reduxkotlin.compose.rememberDispatcher
/*
* @author Yogesh Paliyal
* yogeshpaliyal.foss@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 10:38
*/
@Composable
fun AccountDetailPage(
id: Long?,
viewModel: DetailViewModel = hiltViewModel()
) {
val dispatchAction = rememberDispatcher()
// task value state
val (accountModel, setAccountModel) = remember {
mutableStateOf(
AccountModel()
)
}
// Set initial object
LaunchedEffect(key1 = id) {
viewModel.loadAccount(id) {
setAccountModel(it.copy())
}
}
val goBack: () -> Unit = {
dispatchAction(GoBackAction)
}
val launcher = rememberLauncherForActivityResult(QRScanner()) {
it?.let {
setAccountModel(accountModel.copy(password = it))
}
}
Scaffold(
bottomBar = {
BottomBar(
accountModel,
backPressed = goBack,
onDeleteAccount = {
viewModel.deleteAccount(accountModel, goBack)
}
) {
viewModel.insertOrUpdate(accountModel, goBack)
}
}
) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
Fields(
accountModel = accountModel,
updateAccountModel = { newAccountModel ->
setAccountModel(newAccountModel)
}
) {
launcher.launch(null)
}
}
}
}
@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)
) {
KeyPassInputField(
modifier = Modifier.testTag("accountName"),
placeholder = R.string.account_name,
value = accountModel.title,
setValue = {
updateAccountModel(accountModel.copy(title = it))
}
)
KeyPassInputField(
modifier = Modifier.testTag("username"),
placeholder = R.string.username_email_phone,
value = accountModel.username,
setValue = {
updateAccountModel(accountModel.copy(username = it))
}
)
Column {
val passwordVisible = rememberSaveable { mutableStateOf(false) }
val visualTransformation =
if (passwordVisible.value) VisualTransformation.None else PasswordVisualTransformation()
KeyPassInputField(
modifier = Modifier.testTag("password"),
placeholder = R.string.password,
value = accountModel.password,
setValue = {
updateAccountModel(accountModel.copy(password = it))
},
trailingIcon = {
PasswordTrailingIcon(passwordVisible.value) {
passwordVisible.value = it
}
},
leadingIcon = if (accountModel.id != null) {
null
} else (
{
IconButton(
onClick = {
updateAccountModel(accountModel.copy(password = PasswordGenerator().generatePassword()))
}
) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Refresh),
contentDescription = ""
)
}
}
),
visualTransformation = visualTransformation
)
Button(onClick = scanClicked) {
Row {
Icon(
painter = painterResource(id = R.drawable.ic_twotone_qr_code_scanner_24),
contentDescription = ""
)
Text(text = stringResource(id = R.string.scan_password))
}
}
}
KeyPassInputField(
modifier = Modifier.testTag("tags"),
placeholder = R.string.tags_comma_separated_optional,
value = accountModel.tags,
setValue = {
updateAccountModel(accountModel.copy(tags = it))
}
)
KeyPassInputField(
modifier = Modifier.testTag("website"),
placeholder = R.string.website_url_optional,
value = accountModel.site,
setValue = {
updateAccountModel(accountModel.copy(site = it))
}
)
KeyPassInputField(
modifier = Modifier.testTag("notes"),
placeholder = R.string.notes_optional,
value = accountModel.notes,
setValue = {
updateAccountModel(accountModel.copy(notes = it))
}
)
}
}
@Composable
fun DeleteConfirmation(
openDialog: Boolean,
updateDialogVisibility: (Boolean) -> Unit,
onDelete: () -> Unit
) {
if (openDialog) {
AlertDialog(
onDismissRequest = { /*TODO*/ },
title = {
Text(text = stringResource(id = R.string.delete_account_title))
},
confirmButton = {
TextButton(
modifier = Modifier.testTag("delete"),
onClick = {
updateDialogVisibility(false)
onDelete()
}
) {
Text(text = stringResource(id = R.string.delete))
}
},
text = {
Text(text = stringResource(id = R.string.delete_account_msg))
},
dismissButton = {
TextButton(
onClick = {
updateDialogVisibility(false)
}
) {
Text(text = stringResource(id = R.string.cancel))
}
}
)
}
}
@Composable
fun KeyPassInputField(
modifier: Modifier = Modifier,
@StringRes placeholder: Int,
value: String?,
setValue: (String) -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
OutlinedTextField(
modifier = modifier.fillMaxWidth(),
value = value ?: "",
label = {
Text(text = stringResource(id = placeholder))
},
onValueChange = setValue,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
visualTransformation = visualTransformation
)
}
@Composable
fun PasswordTrailingIcon(
passwordVisible: Boolean,
changePasswordVisibility: (updatedValue: Boolean) -> Unit
) {
val description = if (passwordVisible) "Hide password" else "Show password"
val image = if (passwordVisible) {
Icons.Rounded.Visibility
} else {
Icons.Rounded.VisibilityOff
}
IconButton(onClick = { changePasswordVisibility(!passwordVisible) }) {
Icon(
painter = rememberVectorPainter(image = image),
contentDescription = description
)
}
}
@Composable
fun BottomBar(
accountModel: AccountModel,
backPressed: () -> Unit,
onDeleteAccount: () -> Unit,
onSaveClicked: () -> Unit
) {
val openDialog = remember { mutableStateOf(false) }
BottomAppBar(
actions = {
IconButton(onClick = backPressed) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.ArrowBackIosNew),
contentDescription = "Go Back",
tint = MaterialTheme.colorScheme.onSurface
)
}
if (accountModel.id != null) {
IconButton(
modifier = Modifier.testTag("action_delete"),
onClick = { openDialog.value = 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.value,
updateDialogVisibility = {
openDialog.value = it
},
onDeleteAccount
)
}

View File

@@ -1,65 +1,66 @@
package com.yogeshpaliyal.keypass.ui.detail
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.yogeshpaliyal.common.data.AccountModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 11:52
*/
@HiltViewModel
class DetailViewModel @Inject constructor(
app: Application,
val appDb: com.yogeshpaliyal.common.AppDatabase
) : AndroidViewModel(app) {
private val _accountModel by lazy { MutableLiveData<AccountModel>() }
val accountModel: LiveData<AccountModel> = _accountModel
fun loadAccount(accountId: Long?, getAccount: (AccountModel) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
getAccount(appDb.getDao().getAccount(accountId) ?: AccountModel())
}
}
fun deleteAccount(accountModel: AccountModel, onExecCompleted: () -> Unit) {
viewModelScope.launch {
accountModel.let {
withContext(Dispatchers.IO) {
appDb.getDao().deleteAccount(it)
}
autoBackup()
onExecCompleted()
}
}
}
fun insertOrUpdate(accountModel: AccountModel, onExecCompleted: () -> Unit) {
viewModelScope.launch {
accountModel.let {
withContext(Dispatchers.IO) {
appDb.getDao().insertOrUpdateAccount(it)
autoBackup()
}
}
onExecCompleted()
}
}
private fun autoBackup() {
viewModelScope.launch {
// application.executeAutoBackup()
}
}
}
package com.yogeshpaliyal.keypass.ui.detail
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.common.worker.executeAutoBackup
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 11:52
*/
@HiltViewModel
class DetailViewModel @Inject constructor(
val app: Application,
val appDb: com.yogeshpaliyal.common.AppDatabase
) : AndroidViewModel(app) {
private val _accountModel by lazy { MutableLiveData<AccountModel>() }
val accountModel: LiveData<AccountModel> = _accountModel
fun loadAccount(accountId: Long?, getAccount: (AccountModel) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
getAccount(appDb.getDao().getAccount(accountId) ?: AccountModel())
}
}
fun deleteAccount(accountModel: AccountModel, onExecCompleted: () -> Unit) {
viewModelScope.launch {
accountModel.let {
withContext(Dispatchers.IO) {
appDb.getDao().deleteAccount(it)
}
autoBackup()
onExecCompleted()
}
}
}
fun insertOrUpdate(accountModel: AccountModel, onExecCompleted: () -> Unit) {
viewModelScope.launch {
accountModel.let {
withContext(Dispatchers.IO) {
appDb.getDao().insertOrUpdateAccount(it)
autoBackup()
}
}
onExecCompleted()
}
}
private fun autoBackup() {
viewModelScope.launch {
app.executeAutoBackup()
}
}
}

View File

@@ -1,26 +1,26 @@
package com.yogeshpaliyal.keypass.ui.detail
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.google.zxing.integration.android.IntentIntegrator
class QRScanner : ActivityResultContract<Void?, String?>() {
override fun createIntent(context: Context, input: Void?): Intent {
val intentIntegration = if (context is Activity) {
IntentIntegrator(context)
} else {
null
}
return intentIntegration?.setPrompt("")?.createScanIntent() ?: Intent()
}
override fun parseResult(resultCode: Int, result: Intent?): String? {
return IntentIntegrator.parseActivityResult(
IntentIntegrator.REQUEST_CODE,
resultCode,
result
)?.contents
}
}
package com.yogeshpaliyal.keypass.ui.detail
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.google.zxing.integration.android.IntentIntegrator
class QRScanner : ActivityResultContract<Void?, String?>() {
override fun createIntent(context: Context, input: Void?): Intent {
val intentIntegration = if (context is Activity) {
IntentIntegrator(context)
} else {
null
}
return intentIntegration?.setPrompt("")?.createScanIntent() ?: Intent()
}
override fun parseResult(resultCode: Int, result: Intent?): String? {
return IntentIntegrator.parseActivityResult(
IntentIntegrator.REQUEST_CODE,
resultCode,
result
)?.contents
}
}

View File

@@ -1,23 +1,23 @@
package com.yogeshpaliyal.keypass.ui.generate
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.accompanist.themeadapter.material3.Mdc3Theme
import com.yogeshpaliyal.keypass.ui.generate.ui.GeneratePasswordScreen
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class GeneratePasswordActivity : AppCompatActivity() {
private val viewModel: GeneratePasswordViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Mdc3Theme {
GeneratePasswordScreen(viewModel)
}
}
}
}
package com.yogeshpaliyal.keypass.ui.generate
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.accompanist.themeadapter.material3.Mdc3Theme
import com.yogeshpaliyal.keypass.ui.generate.ui.GeneratePasswordScreen
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class GeneratePasswordActivity : AppCompatActivity() {
private val viewModel: GeneratePasswordViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Mdc3Theme {
GeneratePasswordScreen(viewModel)
}
}
}
}

View File

@@ -1,63 +1,63 @@
package com.yogeshpaliyal.keypass.ui.generate
import androidx.lifecycle.ViewModel
import com.yogeshpaliyal.common.utils.PasswordGenerator
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@HiltViewModel
class GeneratePasswordViewModel @Inject constructor() : ViewModel() {
private val _viewState = MutableStateFlow(GeneratePasswordViewState.Initial)
val viewState = _viewState.asStateFlow()
fun generatePassword() {
val currentViewState = _viewState.value
val passwordGenerator = PasswordGenerator(
length = currentViewState.length,
includeUpperCaseLetters = currentViewState.includeUppercaseLetters,
includeLowerCaseLetters = currentViewState.includeLowercaseLetters,
includeSymbols = currentViewState.includeSymbols,
includeNumbers = currentViewState.includeNumbers
)
_viewState.update {
val newPassword = passwordGenerator.generatePassword()
it.copy(password = newPassword)
}
}
fun onPasswordLengthSliderChange(value: Float) {
_viewState.update {
it.copy(length = value.toInt())
}
}
fun onUppercaseCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeUppercaseLetters = checked)
}
}
fun onLowercaseCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeLowercaseLetters = checked)
}
}
fun onNumbersCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeNumbers = checked)
}
}
fun onSymbolsCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeSymbols = checked)
}
}
}
package com.yogeshpaliyal.keypass.ui.generate
import androidx.lifecycle.ViewModel
import com.yogeshpaliyal.common.utils.PasswordGenerator
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@HiltViewModel
class GeneratePasswordViewModel @Inject constructor() : ViewModel() {
private val _viewState = MutableStateFlow(GeneratePasswordViewState.Initial)
val viewState = _viewState.asStateFlow()
fun generatePassword() {
val currentViewState = _viewState.value
val passwordGenerator = PasswordGenerator(
length = currentViewState.length,
includeUpperCaseLetters = currentViewState.includeUppercaseLetters,
includeLowerCaseLetters = currentViewState.includeLowercaseLetters,
includeSymbols = currentViewState.includeSymbols,
includeNumbers = currentViewState.includeNumbers
)
_viewState.update {
val newPassword = passwordGenerator.generatePassword()
it.copy(password = newPassword)
}
}
fun onPasswordLengthSliderChange(value: Float) {
_viewState.update {
it.copy(length = value.toInt())
}
}
fun onUppercaseCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeUppercaseLetters = checked)
}
}
fun onLowercaseCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeLowercaseLetters = checked)
}
}
fun onNumbersCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeNumbers = checked)
}
}
fun onSymbolsCheckedChange(checked: Boolean) {
_viewState.update {
it.copy(includeSymbols = checked)
}
}
}

View File

@@ -1,21 +1,21 @@
package com.yogeshpaliyal.keypass.ui.generate
data class GeneratePasswordViewState(
val length: Int,
val includeUppercaseLetters: Boolean,
val includeLowercaseLetters: Boolean,
val includeSymbols: Boolean,
val includeNumbers: Boolean,
val password: String
) {
companion object {
val Initial = GeneratePasswordViewState(
length = 10,
includeUppercaseLetters = true,
includeLowercaseLetters = true,
includeSymbols = true,
includeNumbers = true,
password = ""
)
}
}
package com.yogeshpaliyal.keypass.ui.generate
data class GeneratePasswordViewState(
val length: Int,
val includeUppercaseLetters: Boolean,
val includeLowercaseLetters: Boolean,
val includeSymbols: Boolean,
val includeNumbers: Boolean,
val password: String
) {
companion object {
val Initial = GeneratePasswordViewState(
length = 10,
includeUppercaseLetters = true,
includeLowercaseLetters = true,
includeSymbols = true,
includeNumbers = true,
password = ""
)
}
}

View File

@@ -1,226 +1,226 @@
package com.yogeshpaliyal.keypass.ui.generate.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.themeadapter.material3.Mdc3Theme
import com.yogeshpaliyal.keypass.ui.generate.GeneratePasswordViewState
import com.yogeshpaliyal.keypass.ui.generate.ui.components.CheckboxWithLabel
@Composable
fun GeneratePasswordContent(
viewState: GeneratePasswordViewState,
onCopyPasswordClick: () -> Unit,
onGeneratePasswordClick: () -> Unit,
onPasswordLengthChange: (Float) -> Unit,
onUppercaseCheckedChange: (Boolean) -> Unit,
onLowercaseCheckedChange: (Boolean) -> Unit,
onNumbersCheckedChange: (Boolean) -> Unit,
onSymbolsCheckedChange: (Boolean) -> Unit
) {
Scaffold(
floatingActionButton = { GeneratePasswordFab(onGeneratePasswordClick) }
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(16.dp)
) {
FormInputCard(
viewState = viewState,
onCopyPasswordClick = onCopyPasswordClick,
onPasswordLengthChange = onPasswordLengthChange,
onUppercaseCheckedChange = onUppercaseCheckedChange,
onLowercaseCheckedChange = onLowercaseCheckedChange,
onNumbersCheckedChange = onNumbersCheckedChange,
onSymbolsCheckedChange = onSymbolsCheckedChange
)
}
}
}
@Composable
private fun GeneratePasswordFab(onGeneratePasswordClick: () -> Unit) {
FloatingActionButton(
onClick = onGeneratePasswordClick,
shape = RoundedCornerShape(16.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = ""
)
}
}
@Composable
private fun FormInputCard(
viewState: GeneratePasswordViewState,
onCopyPasswordClick: () -> Unit,
onPasswordLengthChange: (Float) -> Unit,
onUppercaseCheckedChange: (Boolean) -> Unit,
onLowercaseCheckedChange: (Boolean) -> Unit,
onNumbersCheckedChange: (Boolean) -> Unit,
onSymbolsCheckedChange: (Boolean) -> Unit
) {
OutlinedCard(
colors = CardDefaults.outlinedCardColors(),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
PasswordTextField(viewState.password, onCopyPasswordClick)
// temporary label until we put slider label on the thumb to display current value.
PasswordLengthInput(viewState.length, onPasswordLengthChange)
UppercaseAlphabetInput(viewState.includeUppercaseLetters, onUppercaseCheckedChange)
LowercaseAlphabetInput(viewState.includeLowercaseLetters, onLowercaseCheckedChange)
NumberInput(viewState.includeNumbers, onNumbersCheckedChange)
SymbolInput(viewState.includeSymbols, onSymbolsCheckedChange)
}
}
}
@Composable
private fun PasswordTextField(
password: String,
onCopyPasswordClick: () -> Unit
) {
OutlinedTextField(
value = password,
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
readOnly = true,
trailingIcon = {
IconButton(
onClick = onCopyPasswordClick
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy password"
)
}
},
label = {
Text(text = "Password")
}
)
}
@Composable
private fun PasswordLengthInput(
length: Int,
onPasswordLengthChange: (Float) -> Unit
) {
Text(text = "Password Length: $length")
Slider(
value = length.toFloat(),
onValueChange = onPasswordLengthChange,
valueRange = 7f..50f,
steps = 43
)
}
@Composable
private fun UppercaseAlphabetInput(
includeUppercaseLetters: Boolean,
onUppercaseCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Uppercase Alphabets",
checked = includeUppercaseLetters,
onCheckedChange = onUppercaseCheckedChange
)
}
@Composable
private fun LowercaseAlphabetInput(
includeLowercaseLetters: Boolean,
onLowercaseCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Lowercase Alphabets",
checked = includeLowercaseLetters,
onCheckedChange = onLowercaseCheckedChange
)
}
@Composable
private fun NumberInput(
includeNumbers: Boolean,
onNumbersCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Numbers",
checked = includeNumbers,
onCheckedChange = onNumbersCheckedChange
)
}
@Composable
private fun SymbolInput(
includeSymbols: Boolean,
onSymbolsCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Symbols",
checked = includeSymbols,
onCheckedChange = onSymbolsCheckedChange
)
}
@Preview(
name = "Night Mode",
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Preview(
name = "Day Mode",
uiMode = Configuration.UI_MODE_NIGHT_NO
)
@Composable
@Suppress("UnusedPrivateMember", "MagicNumber")
private fun GeneratePasswordContentPreview() {
val viewState = GeneratePasswordViewState.Initial
Mdc3Theme {
GeneratePasswordContent(
viewState = viewState,
onGeneratePasswordClick = {},
onCopyPasswordClick = {},
onPasswordLengthChange = {},
onUppercaseCheckedChange = {},
onLowercaseCheckedChange = {},
onNumbersCheckedChange = {},
onSymbolsCheckedChange = {}
)
}
}
package com.yogeshpaliyal.keypass.ui.generate.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.themeadapter.material3.Mdc3Theme
import com.yogeshpaliyal.keypass.ui.generate.GeneratePasswordViewState
import com.yogeshpaliyal.keypass.ui.generate.ui.components.CheckboxWithLabel
@Composable
fun GeneratePasswordContent(
viewState: GeneratePasswordViewState,
onCopyPasswordClick: () -> Unit,
onGeneratePasswordClick: () -> Unit,
onPasswordLengthChange: (Float) -> Unit,
onUppercaseCheckedChange: (Boolean) -> Unit,
onLowercaseCheckedChange: (Boolean) -> Unit,
onNumbersCheckedChange: (Boolean) -> Unit,
onSymbolsCheckedChange: (Boolean) -> Unit
) {
Scaffold(
floatingActionButton = { GeneratePasswordFab(onGeneratePasswordClick) }
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(16.dp)
) {
FormInputCard(
viewState = viewState,
onCopyPasswordClick = onCopyPasswordClick,
onPasswordLengthChange = onPasswordLengthChange,
onUppercaseCheckedChange = onUppercaseCheckedChange,
onLowercaseCheckedChange = onLowercaseCheckedChange,
onNumbersCheckedChange = onNumbersCheckedChange,
onSymbolsCheckedChange = onSymbolsCheckedChange
)
}
}
}
@Composable
private fun GeneratePasswordFab(onGeneratePasswordClick: () -> Unit) {
FloatingActionButton(
onClick = onGeneratePasswordClick,
shape = RoundedCornerShape(16.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = ""
)
}
}
@Composable
private fun FormInputCard(
viewState: GeneratePasswordViewState,
onCopyPasswordClick: () -> Unit,
onPasswordLengthChange: (Float) -> Unit,
onUppercaseCheckedChange: (Boolean) -> Unit,
onLowercaseCheckedChange: (Boolean) -> Unit,
onNumbersCheckedChange: (Boolean) -> Unit,
onSymbolsCheckedChange: (Boolean) -> Unit
) {
OutlinedCard(
colors = CardDefaults.outlinedCardColors(),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
PasswordTextField(viewState.password, onCopyPasswordClick)
// temporary label until we put slider label on the thumb to display current value.
PasswordLengthInput(viewState.length, onPasswordLengthChange)
UppercaseAlphabetInput(viewState.includeUppercaseLetters, onUppercaseCheckedChange)
LowercaseAlphabetInput(viewState.includeLowercaseLetters, onLowercaseCheckedChange)
NumberInput(viewState.includeNumbers, onNumbersCheckedChange)
SymbolInput(viewState.includeSymbols, onSymbolsCheckedChange)
}
}
}
@Composable
private fun PasswordTextField(
password: String,
onCopyPasswordClick: () -> Unit
) {
OutlinedTextField(
value = password,
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
readOnly = true,
trailingIcon = {
IconButton(
onClick = onCopyPasswordClick
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy password"
)
}
},
label = {
Text(text = "Password")
}
)
}
@Composable
private fun PasswordLengthInput(
length: Int,
onPasswordLengthChange: (Float) -> Unit
) {
Text(text = "Password Length: $length")
Slider(
value = length.toFloat(),
onValueChange = onPasswordLengthChange,
valueRange = 7f..50f,
steps = 43
)
}
@Composable
private fun UppercaseAlphabetInput(
includeUppercaseLetters: Boolean,
onUppercaseCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Uppercase Alphabets",
checked = includeUppercaseLetters,
onCheckedChange = onUppercaseCheckedChange
)
}
@Composable
private fun LowercaseAlphabetInput(
includeLowercaseLetters: Boolean,
onLowercaseCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Lowercase Alphabets",
checked = includeLowercaseLetters,
onCheckedChange = onLowercaseCheckedChange
)
}
@Composable
private fun NumberInput(
includeNumbers: Boolean,
onNumbersCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Numbers",
checked = includeNumbers,
onCheckedChange = onNumbersCheckedChange
)
}
@Composable
private fun SymbolInput(
includeSymbols: Boolean,
onSymbolsCheckedChange: (Boolean) -> Unit
) {
CheckboxWithLabel(
label = "Symbols",
checked = includeSymbols,
onCheckedChange = onSymbolsCheckedChange
)
}
@Preview(
name = "Night Mode",
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Preview(
name = "Day Mode",
uiMode = Configuration.UI_MODE_NIGHT_NO
)
@Composable
@Suppress("UnusedPrivateMember", "MagicNumber")
private fun GeneratePasswordContentPreview() {
val viewState = GeneratePasswordViewState.Initial
Mdc3Theme {
GeneratePasswordContent(
viewState = viewState,
onGeneratePasswordClick = {},
onCopyPasswordClick = {},
onPasswordLengthChange = {},
onUppercaseCheckedChange = {},
onLowercaseCheckedChange = {},
onNumbersCheckedChange = {},
onSymbolsCheckedChange = {}
)
}
}

View File

@@ -1,42 +1,42 @@
package com.yogeshpaliyal.keypass.ui.generate.ui
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.generate.GeneratePasswordViewModel
import com.yogeshpaliyal.keypass.ui.generate.ui.utils.copyTextToClipboard
@Composable
fun GeneratePasswordScreen(viewModel: GeneratePasswordViewModel) {
val context = LocalContext.current
// replace collectAsState() with collectAsStateWithLifecycle() when compose version and kotlin version are bumped up.
val viewState by viewModel.viewState.collectAsState()
LaunchedEffect(key1 = Unit) {
viewModel.generatePassword()
}
GeneratePasswordContent(
viewState = viewState,
onGeneratePasswordClick = viewModel::generatePassword,
onCopyPasswordClick = { onCopyPasswordClick(context, viewState.password) },
onPasswordLengthChange = viewModel::onPasswordLengthSliderChange,
onUppercaseCheckedChange = viewModel::onUppercaseCheckedChange,
onLowercaseCheckedChange = viewModel::onLowercaseCheckedChange,
onNumbersCheckedChange = viewModel::onNumbersCheckedChange,
onSymbolsCheckedChange = viewModel::onSymbolsCheckedChange
)
}
private fun onCopyPasswordClick(context: Context, text: String) {
copyTextToClipboard(context = context, text = text, label = "random_password")
Toast
.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT)
.show()
}
package com.yogeshpaliyal.keypass.ui.generate.ui
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.generate.GeneratePasswordViewModel
import com.yogeshpaliyal.keypass.ui.generate.ui.utils.copyTextToClipboard
@Composable
fun GeneratePasswordScreen(viewModel: GeneratePasswordViewModel) {
val context = LocalContext.current
// replace collectAsState() with collectAsStateWithLifecycle() when compose version and kotlin version are bumped up.
val viewState by viewModel.viewState.collectAsState()
LaunchedEffect(key1 = Unit) {
viewModel.generatePassword()
}
GeneratePasswordContent(
viewState = viewState,
onGeneratePasswordClick = viewModel::generatePassword,
onCopyPasswordClick = { onCopyPasswordClick(context, viewState.password) },
onPasswordLengthChange = viewModel::onPasswordLengthSliderChange,
onUppercaseCheckedChange = viewModel::onUppercaseCheckedChange,
onLowercaseCheckedChange = viewModel::onLowercaseCheckedChange,
onNumbersCheckedChange = viewModel::onNumbersCheckedChange,
onSymbolsCheckedChange = viewModel::onSymbolsCheckedChange
)
}
private fun onCopyPasswordClick(context: Context, text: String) {
copyTextToClipboard(context = context, text = text, label = "random_password")
Toast
.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT)
.show()
}

View File

@@ -1,32 +1,32 @@
package com.yogeshpaliyal.keypass.ui.generate.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun CheckboxWithLabel(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
Text(text = label)
}
}
package com.yogeshpaliyal.keypass.ui.generate.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun CheckboxWithLabel(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
Text(text = label)
}
}

View File

@@ -1,17 +1,17 @@
package com.yogeshpaliyal.keypass.ui.generate.ui.utils
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
fun copyTextToClipboard(
context: Context,
text: String,
label: String
) {
val clipboard =
context.getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(label, text)
clipboard.setPrimaryClip(clip)
}
package com.yogeshpaliyal.keypass.ui.generate.ui.utils
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
fun copyTextToClipboard(
context: Context,
text: String,
label: String
) {
val clipboard =
context.getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(label, text)
clipboard.setPrimaryClip(clip)
}

View File

@@ -1,55 +1,55 @@
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
import javax.inject.Inject
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 30-01-2021 23:02
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
application: Application,
val appDb: com.yogeshpaliyal.common.AppDatabase
) :
AndroidViewModel(application) {
private val appDao = appDb.getDao()
val mediator = MediatorLiveData<List<AccountModel>>()
fun queryUpdated(
keyword: String?,
tag: String?,
sortField: String?,
sortAscending: Boolean = true
) {
viewModelScope.launch(Dispatchers.IO) {
if (sortAscending) {
mediator.postValue(appDao.getAllAccountsAscending(keyword ?: "", tag, sortField))
} else {
mediator.postValue(appDao.getAllAccountsDescending(keyword ?: "", tag, sortField))
}
}
}
suspend fun restoreBackup(
keyphrase: String,
contentResolver: ContentResolver,
fileUri: Uri?
): Boolean {
return appDb.restoreBackup(keyphrase, contentResolver, fileUri)
}
}
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
import javax.inject.Inject
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 30-01-2021 23:02
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
application: Application,
val appDb: com.yogeshpaliyal.common.AppDatabase
) :
AndroidViewModel(application) {
private val appDao = appDb.getDao()
val mediator = MediatorLiveData<List<AccountModel>>()
fun queryUpdated(
keyword: String?,
tag: String?,
sortField: String?,
sortAscending: Boolean = true
) {
viewModelScope.launch(Dispatchers.IO) {
if (sortAscending) {
mediator.postValue(appDao.getAllAccountsAscending(keyword ?: "", tag, sortField))
} else {
mediator.postValue(appDao.getAllAccountsDescending(keyword ?: "", tag, sortField))
}
}
}
suspend fun restoreBackup(
keyphrase: String,
contentResolver: ContentResolver,
fileUri: Uri?
): Boolean {
return appDb.restoreBackup(keyphrase, contentResolver, fileUri)
}
}

View File

@@ -1,96 +1,96 @@
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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yogeshpaliyal.keypass.ui.home.components.AccountsList
import com.yogeshpaliyal.keypass.ui.home.components.SearchBar
import com.yogeshpaliyal.keypass.ui.redux.HomeState
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.StateUpdateAction
import org.reduxkotlin.compose.rememberDispatcher
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 09:25
*/
@Composable()
fun Homepage(
mViewModel: DashboardViewModel = viewModel(),
homeState: HomeState
) {
val tag = homeState.tag
val keyword = homeState.keyword
val sortField = homeState.sortField
val sortAscendingOrder = homeState.sortAscending
val listOfAccountsLiveData by mViewModel.mediator.observeAsState()
val dispatchAction = rememberDispatcher()
LaunchedEffect(tag, keyword, sortField, sortAscendingOrder, block = {
mViewModel.queryUpdated(keyword, tag, sortField, sortAscendingOrder)
})
Column(modifier = Modifier.fillMaxSize()) {
SearchBar(keyword, {
dispatchAction(StateUpdateAction(homeState.copy(keyword = it)))
}) { field, order ->
dispatchAction(
StateUpdateAction(
homeState.copy(
sortField = field.value,
sortAscending = order == SortingOrder.Ascending
)
)
)
}
AnimatedVisibility(tag != null) {
LazyRow(
modifier = Modifier.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
content = {
item {
AssistChip(onClick = { }, label = {
Text(text = tag ?: "")
}, trailingIcon = {
IconButton(onClick = {
dispatchAction(NavigationAction(HomeState(), true))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
})
}
}
)
}
AccountsList(listOfAccountsLiveData)
}
}
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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yogeshpaliyal.keypass.ui.home.components.AccountsList
import com.yogeshpaliyal.keypass.ui.home.components.SearchBar
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.actions.StateUpdateAction
import com.yogeshpaliyal.keypass.ui.redux.states.HomeState
import org.reduxkotlin.compose.rememberDispatcher
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 09:25
*/
@Composable()
fun Homepage(
mViewModel: DashboardViewModel = viewModel(),
homeState: HomeState
) {
val tag = homeState.tag
val keyword = homeState.keyword
val sortField = homeState.sortField
val sortAscendingOrder = homeState.sortAscending
val listOfAccountsLiveData by mViewModel.mediator.observeAsState()
val dispatchAction = rememberDispatcher()
LaunchedEffect(tag, keyword, sortField, sortAscendingOrder, block = {
mViewModel.queryUpdated(keyword, tag, sortField, sortAscendingOrder)
})
Column(modifier = Modifier.fillMaxSize()) {
SearchBar(keyword, {
dispatchAction(StateUpdateAction(homeState.copy(keyword = it)))
}) { field, order ->
dispatchAction(
StateUpdateAction(
homeState.copy(
sortField = field.value,
sortAscending = order == SortingOrder.Ascending
)
)
)
}
AnimatedVisibility(tag != null) {
LazyRow(
modifier = Modifier.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
content = {
item {
AssistChip(onClick = { }, label = {
Text(text = tag ?: "")
}, trailingIcon = {
IconButton(onClick = {
dispatchAction(NavigationAction(HomeState(), true))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
})
}
}
)
}
AccountsList(listOfAccountsLiveData)
}
}

View File

@@ -1,20 +1,20 @@
package com.yogeshpaliyal.keypass.ui.home
import androidx.annotation.StringRes
import com.yogeshpaliyal.keypass.R
sealed class SortingOrder(@StringRes val label: Int) {
object Ascending : SortingOrder(R.string.ascending)
object Descending : SortingOrder(R.string.descending)
}
fun getSortingOrderOptions() = mutableListOf(SortingOrder.Ascending, SortingOrder.Descending)
sealed class SortingField(
@StringRes val label: Int,
val value: String,
val sortingOrders: List<SortingOrder>
) {
object Title : SortingField(R.string.account_name, "title", getSortingOrderOptions())
object Username : SortingField(R.string.username_email_phone, "username", getSortingOrderOptions())
}
package com.yogeshpaliyal.keypass.ui.home
import androidx.annotation.StringRes
import com.yogeshpaliyal.keypass.R
sealed class SortingOrder(@StringRes val label: Int) {
object Ascending : SortingOrder(R.string.ascending)
object Descending : SortingOrder(R.string.descending)
}
fun getSortingOrderOptions() = mutableListOf(SortingOrder.Ascending, SortingOrder.Descending)
sealed class SortingField(
@StringRes val label: Int,
val value: String,
val sortingOrders: List<SortingOrder>
) {
object Title : SortingField(R.string.account_name, "title", getSortingOrderOptions())
object Username : SortingField(R.string.username_email_phone, "username", getSortingOrderOptions())
}

View File

@@ -1,242 +1,242 @@
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
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.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yogeshpaliyal.common.constants.AccountType
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.AccountDetailState
import com.yogeshpaliyal.keypass.ui.redux.CopyToClipboard
import com.yogeshpaliyal.keypass.ui.redux.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
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<AccountModel>? = null) {
val dispatch = rememberDispatcher()
if (accounts?.isNotEmpty() == true) {
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))
}
}
}
} else {
NoDataFound()
}
}
@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) }
) {
Row(modifier = Modifier.padding(12.dp)) {
Box(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(60.dp)
.background(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
shape = RoundedCornerShape(50)
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = accountModel.getInitials(),
textAlign = TextAlign.Center
)
if (accountModel.type == AccountType.TOTP) {
WrapWithProgress(accountModel)
}
}
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 16.dp)
) {
Text(
text = accountModel.title ?: "",
style = MaterialTheme.typography.headlineSmall.merge(
TextStyle(
fontSize = 16.sp
)
)
)
RenderUserName(accountModel)
}
IconButton(
modifier = Modifier.align(Alignment.CenterVertically),
onClick = { dispatch(CopyToClipboard(getPassword(accountModel))) }
) {
Icon(
painter = rememberVectorPainter(image = Icons.TwoTone.ContentCopy),
contentDescription = "Copy To Clipboard"
)
}
}
}
}
private fun getUsernameOrOtp(accountModel: AccountModel): String? {
return if (accountModel.type == AccountType.TOTP) accountModel.getOtp() else accountModel.username
}
@Composable
fun RenderUserName(accountModel: AccountModel) {
val (username, setUsername) = remember { mutableStateOf("") }
LaunchedEffect(accountModel) {
if (accountModel.type == AccountType.TOTP) {
while (true) {
setUsername(accountModel.getOtp())
delay(1.seconds)
}
} else {
setUsername(accountModel.username ?: "")
}
}
Text(
text = username,
style = MaterialTheme.typography.bodyMedium.merge(
TextStyle(
fontSize = 14.sp
)
)
)
}
@Composable
fun NoDataFound() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Bottom
) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = R.string.message_no_accounts),
modifier = Modifier
.padding(32.dp)
.align(alignment = Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
Image(
painter = painterResource(R.drawable.ic_undraw_empty_street_sfxm),
contentDescription = ""
)
}
}
private fun getPassword(model: AccountModel): String {
if (model.type == AccountType.TOTP) {
return model.getOtp()
}
return model.password.orEmpty()
}
@Composable
fun WrapWithProgress(accountModel: AccountModel) {
if (accountModel.type != AccountType.TOTP) {
return
}
val infiniteTransition =
rememberInfiniteTransition(accountModel.uniqueId ?: accountModel.title ?: "")
val rotationAnimation = infiniteTransition.animateFloat(
initialValue = 1f - (accountModel.getTOtpProgress().toFloat() / 30),
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(30000, easing = LinearEasing))
)
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(),
progress = rotationAnimation.value
)
}
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
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.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 kotlinx.coroutines.delay
import org.reduxkotlin.compose.rememberDispatcher
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
@Composable
fun AccountsList(accounts: List<AccountModel>? = null) {
val dispatch = rememberDispatcher()
if (accounts?.isNotEmpty() == true) {
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))
}
}
}
} else {
NoDataFound()
}
}
@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) }
) {
Row(modifier = Modifier.padding(12.dp)) {
Box(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(60.dp)
.background(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
shape = RoundedCornerShape(50)
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = accountModel.getInitials(),
textAlign = TextAlign.Center
)
if (accountModel.type == AccountType.TOTP) {
WrapWithProgress(accountModel)
}
}
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(horizontal = 16.dp)
) {
Text(
text = accountModel.title ?: "",
style = MaterialTheme.typography.headlineSmall.merge(
TextStyle(
fontSize = 16.sp
)
)
)
RenderUserName(accountModel)
}
IconButton(
modifier = Modifier.align(Alignment.CenterVertically),
onClick = { dispatch(CopyToClipboard(getPassword(accountModel))) }
) {
Icon(
painter = rememberVectorPainter(image = Icons.TwoTone.ContentCopy),
contentDescription = "Copy To Clipboard"
)
}
}
}
}
private fun getUsernameOrOtp(accountModel: AccountModel): String? {
return if (accountModel.type == AccountType.TOTP) accountModel.getOtp() else accountModel.username
}
@Composable
fun RenderUserName(accountModel: AccountModel) {
val (username, setUsername) = remember { mutableStateOf("") }
LaunchedEffect(accountModel) {
if (accountModel.type == AccountType.TOTP) {
while (true) {
setUsername(accountModel.getOtp())
delay(1.seconds)
}
} else {
setUsername(accountModel.username ?: "")
}
}
Text(
text = username,
style = MaterialTheme.typography.bodyMedium.merge(
TextStyle(
fontSize = 14.sp
)
)
)
}
@Composable
fun NoDataFound() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Bottom
) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = R.string.message_no_accounts),
modifier = Modifier
.padding(32.dp)
.align(alignment = Alignment.CenterHorizontally),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
Image(
painter = painterResource(R.drawable.ic_undraw_empty_street_sfxm),
contentDescription = ""
)
}
}
private fun getPassword(model: AccountModel): String {
if (model.type == AccountType.TOTP) {
return model.getOtp()
}
return model.password.orEmpty()
}
@Composable
fun WrapWithProgress(accountModel: AccountModel) {
if (accountModel.type != AccountType.TOTP) {
return
}
val infiniteTransition =
rememberInfiniteTransition(accountModel.uniqueId ?: accountModel.title ?: "")
val rotationAnimation = infiniteTransition.animateFloat(
initialValue = 1f - (accountModel.getTOtpProgress().toFloat() / 30),
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(30000, easing = LinearEasing))
)
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(),
progress = rotationAnimation.value
)
}

View File

@@ -1,69 +1,69 @@
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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Sort
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import com.yogeshpaliyal.keypass.ui.home.SortingField
import com.yogeshpaliyal.keypass.ui.home.SortingOrder
@Composable
fun SearchBar(
keyword: String?,
updateKeyword: (keyword: String) -> Unit,
updateSorting: (SortingField, SortingOrder) -> Unit
) {
val (isMenuVisible, setMenuVisible) = rememberSaveable { mutableStateOf(false) }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(1f)
.padding(16.dp),
value = keyword ?: "",
placeholder = {
Text(text = "Search Account")
},
onValueChange = { newValue ->
updateKeyword(newValue)
},
trailingIcon = {
Row {
AnimatedVisibility(keyword.isNullOrBlank().not()) {
IconButton(onClick = { updateKeyword("") }) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
}
IconButton(onClick = {
setMenuVisible(!isMenuVisible)
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Sort),
contentDescription = ""
)
}
}
SortingMenu(isMenuVisible, setMenuVisible) { sortingField, order ->
updateSorting(sortingField, order)
setMenuVisible(false)
}
}
)
}
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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Sort
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import com.yogeshpaliyal.keypass.ui.home.SortingField
import com.yogeshpaliyal.keypass.ui.home.SortingOrder
@Composable
fun SearchBar(
keyword: String?,
updateKeyword: (keyword: String) -> Unit,
updateSorting: (SortingField, SortingOrder) -> Unit
) {
val (isMenuVisible, setMenuVisible) = rememberSaveable { mutableStateOf(false) }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(1f)
.padding(16.dp),
value = keyword ?: "",
placeholder = {
Text(text = "Search Account")
},
onValueChange = { newValue ->
updateKeyword(newValue)
},
trailingIcon = {
Row {
AnimatedVisibility(keyword.isNullOrBlank().not()) {
IconButton(onClick = { updateKeyword("") }) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
}
IconButton(onClick = {
setMenuVisible(!isMenuVisible)
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Sort),
contentDescription = ""
)
}
}
SortingMenu(isMenuVisible, setMenuVisible) { sortingField, order ->
updateSorting(sortingField, order)
setMenuVisible(false)
}
}
)
}

View File

@@ -1,45 +1,45 @@
package com.yogeshpaliyal.keypass.ui.home.components
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import com.yogeshpaliyal.keypass.ui.home.SortingField
import com.yogeshpaliyal.keypass.ui.home.SortingOrder
import me.saket.cascade.CascadeDropdownMenu
import me.saket.cascade.rememberCascadeState
@Composable
fun SortingMenu(
isMenuVisible: Boolean,
setMenuVisible: (Boolean) -> Unit,
onOptionSelected: (SortingField, SortingOrder) -> Unit
) {
val state = rememberCascadeState()
val sortingOptions =
remember { mutableListOf(SortingField.Title, SortingField.Username) }
CascadeDropdownMenu(
state = state,
expanded = isMenuVisible,
onDismissRequest = { setMenuVisible(false) }
) {
sortingOptions.forEach { sortingField ->
DropdownMenuItem(
text = { Text(stringResource(id = sortingField.label)) },
children = {
sortingField.sortingOrders.forEach {
DropdownMenuItem(
text = { Text(stringResource(id = it.label)) },
onClick = {
onOptionSelected(sortingField, it)
}
)
}
}
)
}
}
}
package com.yogeshpaliyal.keypass.ui.home.components
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import com.yogeshpaliyal.keypass.ui.home.SortingField
import com.yogeshpaliyal.keypass.ui.home.SortingOrder
import me.saket.cascade.CascadeDropdownMenu
import me.saket.cascade.rememberCascadeState
@Composable
fun SortingMenu(
isMenuVisible: Boolean,
setMenuVisible: (Boolean) -> Unit,
onOptionSelected: (SortingField, SortingOrder) -> Unit
) {
val state = rememberCascadeState()
val sortingOptions =
remember { mutableListOf(SortingField.Title, SortingField.Username) }
CascadeDropdownMenu(
state = state,
expanded = isMenuVisible,
onDismissRequest = { setMenuVisible(false) }
) {
sortingOptions.forEach { sortingField ->
DropdownMenuItem(
text = { Text(stringResource(id = sortingField.label)) },
children = {
sortingField.sortingOrders.forEach {
DropdownMenuItem(
text = { Text(stringResource(id = it.label)) },
onClick = {
onOptionSelected(sortingField, it)
}
)
}
}
)
}
}
}

View File

@@ -1,72 +1,72 @@
package com.yogeshpaliyal.keypass.ui.nav
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 14:11
*/
@HiltViewModel
class BottomNavViewModel @Inject constructor(
application: Application,
val appDb: com.yogeshpaliyal.common.AppDatabase
) : AndroidViewModel(application) {
private val _navigationList: MutableLiveData<List<NavigationModelItem>> = MutableLiveData()
private val tagsDb = appDb.getDao().getTags()
private var tagsList: List<String>? = null
val navigationList: LiveData<List<NavigationModelItem>>
get() = _navigationList
init {
postListUpdate()
viewModelScope.launch {
tagsDb.collect {
tagsList = it
postListUpdate()
}
}
}
/**
* Set the currently selected menu item.
*
* @return true if the currently selected item has changed.
*/
fun setNavigationMenuItemChecked(id: Int): Boolean {
var updated = false
NavigationModel.navigationMenuItems.forEachIndexed { index, item ->
val shouldCheck = item.id == id
if (item.checked != shouldCheck) {
NavigationModel.navigationMenuItems[index] = item.copy(checked = shouldCheck)
updated = true
}
}
if (updated) postListUpdate()
return updated
}
private fun postListUpdate() {
val newList = if (tagsList.isNullOrEmpty().not()) {
NavigationModel.navigationMenuItems + NavigationModelItem.NavDivider("Tags") + (
tagsList?.filter { it != null }
?.map { NavigationModelItem.NavTagItem(it) } ?: listOf()
)
} else {
NavigationModel.navigationMenuItems
}
_navigationList.value = newList
}
}
package com.yogeshpaliyal.keypass.ui.nav
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 14:11
*/
@HiltViewModel
class BottomNavViewModel @Inject constructor(
application: Application,
val appDb: com.yogeshpaliyal.common.AppDatabase
) : AndroidViewModel(application) {
private val _navigationList: MutableLiveData<List<NavigationModelItem>> = MutableLiveData()
private val tagsDb = appDb.getDao().getTags()
private var tagsList: List<String>? = null
val navigationList: LiveData<List<NavigationModelItem>>
get() = _navigationList
init {
postListUpdate()
viewModelScope.launch {
tagsDb.collect {
tagsList = it
postListUpdate()
}
}
}
/**
* Set the currently selected menu item.
*
* @return true if the currently selected item has changed.
*/
fun setNavigationMenuItemChecked(id: Int): Boolean {
var updated = false
NavigationModel.navigationMenuItems.forEachIndexed { index, item ->
val shouldCheck = item.id == id
if (item.checked != shouldCheck) {
NavigationModel.navigationMenuItems[index] = item.copy(checked = shouldCheck)
updated = true
}
}
if (updated) postListUpdate()
return updated
}
private fun postListUpdate() {
val newList = if (tagsList.isNullOrEmpty().not()) {
NavigationModel.navigationMenuItems + NavigationModelItem.NavDivider("Tags") + (
tagsList?.filter { it != null }
?.map { NavigationModelItem.NavTagItem(it) } ?: listOf()
)
} else {
NavigationModel.navigationMenuItems
}
_navigationList.value = newList
}
}

View File

@@ -1,305 +1,312 @@
package com.yogeshpaliyal.keypass.ui.nav
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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 com.yogeshpaliyal.keypass.BuildConfig
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
import com.yogeshpaliyal.keypass.ui.redux.HomeState
import com.yogeshpaliyal.keypass.ui.redux.KeyPassRedux
import com.yogeshpaliyal.keypass.ui.redux.KeyPassState
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
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
import org.reduxkotlin.compose.rememberDispatcher
import org.reduxkotlin.compose.selectState
import java.util.Locale
@AndroidEntryPoint
class DashboardComposeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG.not()) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
setContent {
KeyPassTheme {
StoreProvider(store = KeyPassRedux.createStore()) {
Dashboard()
}
}
}
}
}
@Composable
fun Dashboard() {
val systemBackPress by selectState<KeyPassState, Boolean> { this.systemBackPress }
val context = LocalContext.current
val dispatch = rememberDispatcher()
BackHandler(!systemBackPress) {
dispatch(GoBackAction)
}
LaunchedEffect(key1 = systemBackPress, block = {
if (systemBackPress) {
(context as? AppCompatActivity)?.onBackPressed()
}
})
DisposableEffect(KeyPassRedux, context) {
dispatch(UpdateContextAction(context))
onDispose {
dispatch(UpdateContextAction(null))
}
}
Scaffold(bottomBar = {
KeyPassBottomBar()
}) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
CurrentPage()
OptionBottomBar()
}
}
}
@Composable
fun CurrentPage() {
val currentScreen by selectState<KeyPassState, ScreenState> { this.currentScreen }
currentScreen.let {
when (it) {
is HomeState -> {
Homepage(homeState = it)
}
is SettingsState -> {
MySettingCompose()
}
is AccountDetailState -> {
AccountDetailPage(id = it.accountId)
}
is AuthState -> {
AuthScreen(it)
}
is TotpDetailState -> {
}
}
}
}
@Composable
fun OptionBottomBar(
viewModel: BottomNavViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val bottomSheetState by selectState<KeyPassState, BottomSheetState?> { this.bottomSheet }
if (bottomSheetState?.isBottomSheetOpen != true) {
return
}
val dispatchAction = rememberDispatcher()
val navigationItems by viewModel.navigationList.observeAsState()
ModalBottomSheet(onDismissRequest = {
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}) {
LazyColumn(
modifier = Modifier
.fillMaxWidth(1f)
.padding(16.dp)
) {
if (navigationItems != null) {
items(navigationItems!!) {
when (it) {
is NavigationModelItem.NavDivider -> {
NavItemSection(it)
}
is NavigationModelItem.NavTagItem -> {
NavMenuFolder(folder = it) {
dispatchAction(NavigationAction(HomeState(tag = it.tag), false))
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}
}
is NavigationModelItem.NavMenuItem -> {
NavItem(it) {
dispatchAction(it.action)
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}
}
}
}
}
}
}
}
@Composable
fun NavMenuFolder(folder: NavigationModelItem.NavTagItem, onClick: () -> Unit) {
Box(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(
bounded = true
),
onClick = onClick
)
.padding(16.dp)
.fillMaxWidth(1f)
) {
Text(
text = folder.tag,
style = MaterialTheme.typography.titleMedium
)
}
}
@Composable
fun NavItem(item: NavigationModelItem.NavMenuItem, onClick: () -> Unit) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true),
onClick = onClick
)
.padding(16.dp)
.fillMaxWidth(1f)
) {
Icon(
painter = painterResource(id = item.icon),
contentDescription = ""
)
Spacer(modifier = Modifier.width(32.dp))
Text(
text = stringResource(id = item.titleRes),
style = MaterialTheme.typography.titleMedium
)
}
}
@Composable
fun NavItemSection(divider: NavigationModelItem.NavDivider) {
Column(modifier = Modifier.padding(16.dp)) {
Divider()
Spacer(modifier = Modifier.height(32.dp))
Text(
text = divider.title.uppercase(Locale.getDefault()),
style = MaterialTheme.typography.labelMedium,
fontSize = TextUnit(12f, TextUnitType.Sp)
)
}
}
@Composable
fun KeyPassBottomBar() {
val showMainBottomAppBar by selectState<KeyPassState, Boolean> { this.currentScreen.showMainBottomAppBar }
val dispatchAction = rememberDispatcher()
if (!showMainBottomAppBar) {
return
}
BottomAppBar(actions = {
IconButton(onClick = {
dispatchAction(BottomSheetAction.HomeNavigationMenu(true))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Menu),
contentDescription = "Menu",
tint = MaterialTheme.colorScheme.onSurface
)
}
IconButton(onClick = {
dispatchAction(NavigationAction(SettingsState))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Settings),
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurface
)
}
}, floatingActionButton = {
FloatingActionButton(onClick = {
dispatchAction(NavigationAction(AccountDetailState()))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Add),
contentDescription = "Add"
)
}
})
}
package com.yogeshpaliyal.keypass.ui.nav
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
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 com.yogeshpaliyal.keypass.BuildConfig
import com.yogeshpaliyal.keypass.ui.auth.AuthScreen
import com.yogeshpaliyal.keypass.ui.backup.BackupScreen
import com.yogeshpaliyal.keypass.ui.detail.AccountDetailPage
import com.yogeshpaliyal.keypass.ui.home.Homepage
import com.yogeshpaliyal.keypass.ui.redux.KeyPassRedux
import com.yogeshpaliyal.keypass.ui.redux.actions.BottomSheetAction
import com.yogeshpaliyal.keypass.ui.redux.actions.GoBackAction
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.actions.UpdateContextAction
import com.yogeshpaliyal.keypass.ui.redux.states.AccountDetailState
import com.yogeshpaliyal.keypass.ui.redux.states.AuthState
import com.yogeshpaliyal.keypass.ui.redux.states.BackupScreenState
import com.yogeshpaliyal.keypass.ui.redux.states.BottomSheetState
import com.yogeshpaliyal.keypass.ui.redux.states.HomeState
import com.yogeshpaliyal.keypass.ui.redux.states.KeyPassState
import com.yogeshpaliyal.keypass.ui.redux.states.ScreenState
import com.yogeshpaliyal.keypass.ui.redux.states.SettingsState
import com.yogeshpaliyal.keypass.ui.redux.states.TotpDetailState
import com.yogeshpaliyal.keypass.ui.settings.MySettingCompose
import com.yogeshpaliyal.keypass.ui.style.KeyPassTheme
import dagger.hilt.android.AndroidEntryPoint
import org.reduxkotlin.compose.StoreProvider
import org.reduxkotlin.compose.rememberDispatcher
import org.reduxkotlin.compose.selectState
import java.util.Locale
@AndroidEntryPoint
class DashboardComposeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG.not()) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
setContent {
KeyPassTheme {
StoreProvider(store = KeyPassRedux.createStore()) {
Dashboard()
}
}
}
}
}
@Composable
fun Dashboard() {
val systemBackPress by selectState<KeyPassState, Boolean> { this.systemBackPress }
val context = LocalContext.current
val dispatch = rememberDispatcher()
BackHandler(!systemBackPress) {
dispatch(GoBackAction)
}
LaunchedEffect(key1 = systemBackPress, block = {
if (systemBackPress) {
(context as? AppCompatActivity)?.onBackPressed()
}
})
DisposableEffect(KeyPassRedux, context) {
dispatch(UpdateContextAction(context))
onDispose {
dispatch(UpdateContextAction(null))
}
}
Scaffold(bottomBar = {
KeyPassBottomBar()
}) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
CurrentPage()
OptionBottomBar()
}
}
}
@Composable
fun CurrentPage() {
val currentScreen by selectState<KeyPassState, ScreenState> { this.currentScreen }
currentScreen.let {
when (it) {
is HomeState -> {
Homepage(homeState = it)
}
is SettingsState -> {
MySettingCompose()
}
is AccountDetailState -> {
AccountDetailPage(id = it.accountId)
}
is AuthState -> {
AuthScreen(it)
}
is BackupScreenState -> {
BackupScreen(state = it)
}
is TotpDetailState -> {
}
}
}
}
@Composable
fun OptionBottomBar(
viewModel: BottomNavViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val bottomSheetState by selectState<KeyPassState, BottomSheetState?> { this.bottomSheet }
if (bottomSheetState?.isBottomSheetOpen != true) {
return
}
val dispatchAction = rememberDispatcher()
val navigationItems by viewModel.navigationList.observeAsState()
ModalBottomSheet(onDismissRequest = {
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}) {
LazyColumn(
modifier = Modifier
.fillMaxWidth(1f)
.padding(16.dp)
) {
if (navigationItems != null) {
items(navigationItems!!) {
when (it) {
is NavigationModelItem.NavDivider -> {
NavItemSection(it)
}
is NavigationModelItem.NavTagItem -> {
NavMenuFolder(folder = it) {
dispatchAction(NavigationAction(HomeState(tag = it.tag), false))
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}
}
is NavigationModelItem.NavMenuItem -> {
NavItem(it) {
dispatchAction(it.action)
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}
}
}
}
}
}
}
}
@Composable
fun NavMenuFolder(folder: NavigationModelItem.NavTagItem, onClick: () -> Unit) {
Box(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(
bounded = true
),
onClick = onClick
)
.padding(16.dp)
.fillMaxWidth(1f)
) {
Text(
text = folder.tag,
style = MaterialTheme.typography.titleMedium
)
}
}
@Composable
fun NavItem(item: NavigationModelItem.NavMenuItem, onClick: () -> Unit) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true),
onClick = onClick
)
.padding(16.dp)
.fillMaxWidth(1f)
) {
Icon(
painter = painterResource(id = item.icon),
contentDescription = ""
)
Spacer(modifier = Modifier.width(32.dp))
Text(
text = stringResource(id = item.titleRes),
style = MaterialTheme.typography.titleMedium
)
}
}
@Composable
fun NavItemSection(divider: NavigationModelItem.NavDivider) {
Column(modifier = Modifier.padding(16.dp)) {
Divider()
Spacer(modifier = Modifier.height(32.dp))
Text(
text = divider.title.uppercase(Locale.getDefault()),
style = MaterialTheme.typography.labelMedium,
fontSize = TextUnit(12f, TextUnitType.Sp)
)
}
}
@Composable
fun KeyPassBottomBar() {
val showMainBottomAppBar by selectState<KeyPassState, Boolean> { this.currentScreen.showMainBottomAppBar }
val dispatchAction = rememberDispatcher()
if (!showMainBottomAppBar) {
return
}
BottomAppBar(actions = {
IconButton(onClick = {
dispatchAction(BottomSheetAction.HomeNavigationMenu(true))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Menu),
contentDescription = "Menu",
tint = MaterialTheme.colorScheme.onSurface
)
}
IconButton(onClick = {
dispatchAction(NavigationAction(SettingsState))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Settings),
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurface
)
}
}, floatingActionButton = {
FloatingActionButton(modifier = Modifier.testTag("btnAdd"), onClick = {
dispatchAction(NavigationAction(AccountDetailState()))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Add),
contentDescription = "Add"
)
}
})
}

View File

@@ -1,37 +1,37 @@
package com.yogeshpaliyal.keypass.ui.nav
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.HomeState
import com.yogeshpaliyal.keypass.ui.redux.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
object NavigationModel {
const val HOME = 0
const val GENERATE_PASSWORD = 1
const val ADD_TOPT = 2
var navigationMenuItems = mutableListOf(
NavigationModelItem.NavMenuItem(
id = HOME,
icon = R.drawable.ic_twotone_home_24,
titleRes = R.string.home,
checked = false,
action = NavigationAction(HomeState(), true)
),
NavigationModelItem.NavMenuItem(
id = GENERATE_PASSWORD,
icon = R.drawable.ic_twotone_vpn_key_24,
titleRes = R.string.generate_password,
checked = false,
action = IntentNavigation.GeneratePassword
),
NavigationModelItem.NavMenuItem(
id = ADD_TOPT,
icon = R.drawable.ic_twotone_totp,
titleRes = R.string.add_totp,
checked = false,
action = IntentNavigation.AddTOTP()
)
)
}
package com.yogeshpaliyal.keypass.ui.nav
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
object NavigationModel {
const val HOME = 0
const val GENERATE_PASSWORD = 1
const val ADD_TOPT = 2
var navigationMenuItems = mutableListOf(
NavigationModelItem.NavMenuItem(
id = HOME,
icon = R.drawable.ic_twotone_home_24,
titleRes = R.string.home,
checked = false,
action = NavigationAction(HomeState(), true)
),
NavigationModelItem.NavMenuItem(
id = GENERATE_PASSWORD,
icon = R.drawable.ic_twotone_vpn_key_24,
titleRes = R.string.generate_password,
checked = false,
action = IntentNavigation.GeneratePassword
),
NavigationModelItem.NavMenuItem(
id = ADD_TOPT,
icon = R.drawable.ic_twotone_totp,
titleRes = R.string.add_totp,
checked = false,
action = IntentNavigation.AddTOTP()
)
)
}

View File

@@ -1,24 +1,24 @@
package com.yogeshpaliyal.keypass.ui.nav
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.yogeshpaliyal.keypass.ui.redux.Action
sealed class NavigationModelItem {
data class NavMenuItem(
val id: Int,
@DrawableRes val icon: Int,
@StringRes val titleRes: Int,
var checked: Boolean,
var action: Action
) : NavigationModelItem()
/**
* A class which is used to show a section divider (a subtitle and underline) between
* sections of different NavigationModelItem types.
*/
data class NavDivider(val title: String) : NavigationModelItem()
data class NavTagItem(val tag: String) : NavigationModelItem()
}
package com.yogeshpaliyal.keypass.ui.nav
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.yogeshpaliyal.keypass.ui.redux.actions.Action
sealed class NavigationModelItem {
data class NavMenuItem(
val id: Int,
@DrawableRes val icon: Int,
@StringRes val titleRes: Int,
var checked: Boolean,
var action: Action
) : NavigationModelItem()
/**
* A class which is used to show a section divider (a subtitle and underline) between
* sections of different NavigationModelItem types.
*/
data class NavDivider(val title: String) : NavigationModelItem()
data class NavTagItem(val tag: String) : NavigationModelItem()
}

View File

@@ -1,33 +0,0 @@
package com.yogeshpaliyal.keypass.ui.redux
import android.content.Context
import android.os.Bundle
import androidx.annotation.StringRes
sealed interface Action
class UpdateContextAction(val context: Context?) : Action
object GoBackAction : Action
data class NavigationAction(val state: ScreenState, val clearBackStack: Boolean = false) : Action
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
}
data class ToastAction(@StringRes val text: Int) : Action
data class CopyToClipboard(val password: String) : Action
sealed class BottomSheetAction(
val route: String,
val globalIsBottomSheetOpen: Boolean,
val globalArgs: Bundle? = null
) : Action {
data class HomeNavigationMenu(val isBottomSheetOpen: Boolean, val args: Bundle? = null) :
BottomSheetAction(BottomSheetRoutes.HOME_NAV_MENU, isBottomSheetOpen, args)
}

View File

@@ -0,0 +1,5 @@
package com.yogeshpaliyal.keypass.ui.redux
object BottomSheetRoutes {
const val HOME_NAV_MENU = "/home/navMenu"
}

View File

@@ -1,124 +1,103 @@
package com.yogeshpaliyal.keypass.ui.redux
import android.content.ClipData
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
import org.reduxkotlin.createStore
import org.reduxkotlin.middleware
object KeyPassRedux {
private var arrPages = mutableListOf<ScreenState>()
private val reducer: Reducer<KeyPassState> = { state, action ->
when (action) {
is NavigationAction -> {
if (action.clearBackStack) {
arrPages.clear()
} else {
arrPages.add(state.currentScreen)
}
state.copy(currentScreen = action.state)
}
is StateUpdateAction -> {
state.copy(currentScreen = action.state)
}
is CopyToClipboard -> {
state.context?.let {
val clipboard = ContextCompat.getSystemService(
it,
ClipboardManager::class.java
)
val clip = ClipData.newPlainText("KeyPass", action.password)
clipboard?.setPrimaryClip(clip)
Toast.makeText(
it,
R.string.copied_to_clipboard,
Toast.LENGTH_SHORT
)
.show()
}
state
}
is UpdateContextAction -> {
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) {
state.copy(currentScreen = lastItem)
} else {
state.copy(systemBackPress = true)
}
}
is BottomSheetAction -> {
state.copy(
bottomSheet = BottomSheetState(
action.route,
action.globalArgs,
action.globalIsBottomSheetOpen
)
)
}
else -> state
}
}
private val intentNavigationMiddleware = middleware<KeyPassState> { store, next, action ->
val state = store.state
when (action) {
is IntentNavigation.GeneratePassword -> {
val intent = Intent(state.context, GeneratePasswordActivity::class.java)
state.context?.startActivity(intent)
}
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)
}
fun createStore() =
createStore(reducer, generateDefaultState(), applyMiddleware(intentNavigationMiddleware))
}
package com.yogeshpaliyal.keypass.ui.redux
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.actions.BottomSheetAction
import com.yogeshpaliyal.keypass.ui.redux.actions.CopyToClipboard
import com.yogeshpaliyal.keypass.ui.redux.actions.GoBackAction
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.actions.StateUpdateAction
import com.yogeshpaliyal.keypass.ui.redux.actions.ToastAction
import com.yogeshpaliyal.keypass.ui.redux.actions.UpdateContextAction
import com.yogeshpaliyal.keypass.ui.redux.middlewares.intentNavigationMiddleware
import com.yogeshpaliyal.keypass.ui.redux.states.BottomSheetState
import com.yogeshpaliyal.keypass.ui.redux.states.KeyPassState
import com.yogeshpaliyal.keypass.ui.redux.states.ScreenState
import com.yogeshpaliyal.keypass.ui.redux.states.generateDefaultState
import org.reduxkotlin.Reducer
import org.reduxkotlin.applyMiddleware
import org.reduxkotlin.createStore
object KeyPassRedux {
private var arrPages = mutableListOf<ScreenState>()
private val reducer: Reducer<KeyPassState> = { state, action ->
when (action) {
is NavigationAction -> {
if (action.clearBackStack) {
arrPages.clear()
} else {
arrPages.add(state.currentScreen)
}
state.copy(currentScreen = action.state)
}
is StateUpdateAction -> {
state.copy(currentScreen = action.state)
}
is CopyToClipboard -> {
state.context?.let {
val clipboard = ContextCompat.getSystemService(
it,
ClipboardManager::class.java
)
val clip = ClipData.newPlainText("KeyPass", action.password)
clipboard?.setPrimaryClip(clip)
Toast.makeText(
it,
R.string.copied_to_clipboard,
Toast.LENGTH_SHORT
)
.show()
}
state
}
is UpdateContextAction -> {
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) {
state.copy(currentScreen = lastItem)
} else {
state.copy(systemBackPress = true)
}
}
is BottomSheetAction -> {
state.copy(
bottomSheet = BottomSheetState(
action.route,
action.globalArgs,
action.globalIsBottomSheetOpen
)
)
}
else -> state
}
}
fun createStore() =
createStore(
reducer,
generateDefaultState(),
applyMiddleware(intentNavigationMiddleware)
)
}

View File

@@ -1,13 +0,0 @@
package com.yogeshpaliyal.keypass.ui.redux
object ScreenRoutes {
const val HOME = "home?tag={tag}"
const val SETTINGS = "/settings"
const val ADD_ACCOUNT = "addAccount?accountId={accountId}"
const val GENERATE_PASSWORD = "/generatePassword"
const val ADD_TOTP = "/addTOTP"
}
object BottomSheetRoutes {
const val HOME_NAV_MENU = "/home/navMenu"
}

View File

@@ -1,37 +0,0 @@
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? = null,
val systemBackPress: Boolean = false
)
sealed class ScreenState(val showMainBottomAppBar: Boolean = false)
data class HomeState(val keyword: String? = null, val tag: String? = null, val sortField: String? = null, val sortAscending: Boolean = true) : ScreenState(true)
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,
val isBottomSheetOpen: Boolean = false
)
fun generateDefaultState(): KeyPassState {
val currentPage = AuthState.Login
val bottomSheet = BottomSheetState(BottomSheetRoutes.HOME_NAV_MENU, isBottomSheetOpen = false)
return KeyPassState(currentScreen = currentPage, bottomSheet = bottomSheet)
}

View File

@@ -0,0 +1,11 @@
package com.yogeshpaliyal.keypass.ui.redux.actions
import android.content.Context
import com.yogeshpaliyal.keypass.ui.redux.states.ScreenState
sealed interface Action
class UpdateContextAction(val context: Context?) : Action
data class NavigationAction(val state: ScreenState, val clearBackStack: Boolean = false) : Action
data class StateUpdateAction(val state: ScreenState) : Action

View File

@@ -0,0 +1,13 @@
package com.yogeshpaliyal.keypass.ui.redux.actions
import android.os.Bundle
import com.yogeshpaliyal.keypass.ui.redux.BottomSheetRoutes
sealed class BottomSheetAction(
val route: String,
val globalIsBottomSheetOpen: Boolean,
val globalArgs: Bundle? = null
) : Action {
data class HomeNavigationMenu(val isBottomSheetOpen: Boolean, val args: Bundle? = null) :
BottomSheetAction(BottomSheetRoutes.HOME_NAV_MENU, isBottomSheetOpen, args)
}

View File

@@ -0,0 +1,8 @@
package com.yogeshpaliyal.keypass.ui.redux.actions
sealed interface IntentNavigation : Action {
object GeneratePassword : IntentNavigation
object BackupActivity : IntentNavigation
object ShareApp : IntentNavigation
data class AddTOTP(val accountId: String? = null) : IntentNavigation
}

View File

@@ -0,0 +1,8 @@
package com.yogeshpaliyal.keypass.ui.redux.actions
import androidx.annotation.StringRes
data class ToastAction(@StringRes val text: Int) : Action
data class CopyToClipboard(val password: String) : Action
object GoBackAction : Action

View File

@@ -0,0 +1,46 @@
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
import org.reduxkotlin.middleware
val intentNavigationMiddleware = middleware<KeyPassState> { store, next, action ->
val state = store.state
when (action) {
is IntentNavigation.GeneratePassword -> {
val intent = Intent(state.context, GeneratePasswordActivity::class.java)
state.context?.startActivity(intent)
}
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)
}

View File

@@ -0,0 +1,3 @@
package com.yogeshpaliyal.keypass.ui.redux.states
data class AccountDetailState(val accountId: Long? = null) : ScreenState()

View File

@@ -0,0 +1,10 @@
package com.yogeshpaliyal.keypass.ui.redux.states
import androidx.annotation.StringRes
import com.yogeshpaliyal.keypass.R
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)
}

View File

@@ -0,0 +1,29 @@
package com.yogeshpaliyal.keypass.ui.redux.states
import android.net.Uri
import java.net.URLDecoder
data class BackupScreenState(
val isBackupEnabled: Boolean? = null,
val isAutoBackupEnabled: Boolean? = null,
val overrideAutoBackup: Boolean? = null,
val lastBackupTime: Long? = null,
val backupDirectory: Uri? = null,
val dialog: BackupDialog? = null
) : ScreenState() {
fun getFormattedBackupDirectory(): String {
if (backupDirectory == null) {
return ""
}
val splittedPath = URLDecoder.decode(backupDirectory.toString(), "utf-8").split("/")
return splittedPath.get(splittedPath.lastIndex)
}
}
sealed interface BackupDialog
object SelectKeyphraseType : BackupDialog
object CustomKeyphrase : BackupDialog
object ShowKeyphrase : BackupDialog

View File

@@ -0,0 +1,9 @@
package com.yogeshpaliyal.keypass.ui.redux.states
import android.os.Bundle
data class BottomSheetState(
val path: String,
val args: Bundle? = null,
val isBottomSheetOpen: Boolean = false
)

View File

@@ -0,0 +1,8 @@
package com.yogeshpaliyal.keypass.ui.redux.states
data class HomeState(
val keyword: String? = null,
val tag: String? = null,
val sortField: String? = null,
val sortAscending: Boolean = true
) : ScreenState(true)

View File

@@ -0,0 +1,17 @@
package com.yogeshpaliyal.keypass.ui.redux.states
import android.content.Context
import com.yogeshpaliyal.keypass.ui.redux.BottomSheetRoutes
data class KeyPassState(
val context: Context? = null,
val currentScreen: ScreenState,
val bottomSheet: BottomSheetState? = null,
val systemBackPress: Boolean = false
)
fun generateDefaultState(): KeyPassState {
val currentPage = AuthState.Login
val bottomSheet = BottomSheetState(BottomSheetRoutes.HOME_NAV_MENU, isBottomSheetOpen = false)
return KeyPassState(currentScreen = currentPage, bottomSheet = bottomSheet)
}

View File

@@ -0,0 +1,3 @@
package com.yogeshpaliyal.keypass.ui.redux.states
sealed class ScreenState(val showMainBottomAppBar: Boolean = false)

View File

@@ -0,0 +1,3 @@
package com.yogeshpaliyal.keypass.ui.redux.states
object SettingsState : ScreenState(true)

View File

@@ -0,0 +1,3 @@
package com.yogeshpaliyal.keypass.ui.redux.states
data class TotpDetailState(val accountId: String? = null) : ScreenState()

View File

@@ -1,244 +1,249 @@
package com.yogeshpaliyal.keypass.ui.settings
import android.net.Uri
import android.os.Bundle
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.yogeshpaliyal.common.utils.BACKUP_KEY_LENGTH
import com.yogeshpaliyal.common.utils.email
import com.yogeshpaliyal.keypass.R
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
@AndroidEntryPoint
class MySettingsFragment : PreferenceFragmentCompat() {
@Inject
lateinit var appDb: com.yogeshpaliyal.common.AppDatabase
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
}
}
@Composable
fun RestoreDialog(
selectedFile: Uri,
hideDialog: () -> Unit,
mViewModel: DashboardViewModel = hiltViewModel()
) {
val (keyphrase, setKeyPhrase) = remember {
mutableStateOf("")
}
val dispatchAction = rememberTypedDispatcher<Action>()
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<Action>()
val context = LocalContext.current
val (result, setResult) = remember { mutableStateOf<Uri?>(null) }
val launcher = rememberLauncherForActivityResult(OpenKeyPassBackup()) {
setResult(it)
}
result?.let {
RestoreDialog(
selectedFile = it,
hideDialog = {
setResult(null)
}
)
}
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)
}
}
}
@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
) {
Box(modifier = Modifier.width(56.dp), Alignment.CenterStart) {
if (icon != null) {
Icon(painter = rememberVectorPainter(image = icon), contentDescription = "")
}
}
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))
}
}
}
}
package com.yogeshpaliyal.keypass.ui.settings
import android.net.Uri
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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.yogeshpaliyal.common.utils.BACKUP_KEY_LENGTH
import com.yogeshpaliyal.common.utils.email
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.home.DashboardViewModel
import com.yogeshpaliyal.keypass.ui.redux.actions.Action
import com.yogeshpaliyal.keypass.ui.redux.actions.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.actions.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.actions.ToastAction
import com.yogeshpaliyal.keypass.ui.redux.states.BackupScreenState
import kotlinx.coroutines.launch
import org.reduxkotlin.compose.rememberTypedDispatcher
@Composable
fun RestoreDialog(
selectedFile: Uri,
hideDialog: () -> Unit,
mViewModel: DashboardViewModel = hiltViewModel()
) {
val (keyphrase, setKeyPhrase) = remember {
mutableStateOf("")
}
val dispatchAction = rememberTypedDispatcher<Action>()
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<Action>()
val context = LocalContext.current
val (result, setResult) = remember { mutableStateOf<Uri?>(null) }
val launcher = rememberLauncherForActivityResult(OpenKeyPassBackup()) {
setResult(it)
}
result?.let {
RestoreDialog(
selectedFile = it,
hideDialog = {
setResult(null)
}
)
}
Column {
PreferenceItem(title = R.string.security, isCategory = true)
PreferenceItem(
title = R.string.credentials_backups,
summary = R.string.credentials_backups_desc
) {
dispatchAction(NavigationAction(BackupScreenState()))
}
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)
}
}
}
@Composable
fun PreferenceItem(
@StringRes title: Int? = null,
@StringRes summary: Int? = null,
summaryStr: String? = null,
icon: ImageVector? = null,
isCategory: Boolean = false,
onClickItem: (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth(1f)
.widthIn(48.dp)
.padding(horizontal = 16.dp)
.clickable(onClickItem != null) {
onClickItem?.invoke()
},
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.width(56.dp), Alignment.CenterStart) {
if (icon != null) {
Icon(painter = rememberVectorPainter(image = icon), contentDescription = "")
}
}
Column(
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxWidth(1f)
) {
if (title != null) {
if (isCategory) {
CategoryTitle(title = title)
} else {
PreferenceItemTitle(title = title)
}
}
if (summary != null || summaryStr != null) {
val summaryText = if (summary != null) {
stringResource(id = summary)
} else {
summaryStr
}
if (summaryText != null) {
Text(text = summaryText, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
@Composable
private fun CategoryTitle(title: Int) {
Text(
text = stringResource(id = title),
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.titleMedium
)
}
@Composable
private fun PreferenceItemTitle(title: Int) {
Text(
text = stringResource(id = title),
style = MaterialTheme.typography.bodyLarge
)
}

View File

@@ -1,14 +1,12 @@
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()
}
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)

View File

@@ -1,20 +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<String>): 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
}
}
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<String>): 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
}
}

View File

@@ -1,11 +1,11 @@
package com.yogeshpaliyal.keypass.ui.style
import androidx.compose.runtime.Composable
import com.google.accompanist.themeadapter.material3.Mdc3Theme
@Composable
fun KeyPassTheme(content: @Composable () -> Unit) {
Mdc3Theme {
content()
}
}
package com.yogeshpaliyal.keypass.ui.style
import androidx.compose.runtime.Composable
import com.google.accompanist.themeadapter.material3.Mdc3Theme
@Composable
fun KeyPassTheme(content: @Composable () -> Unit) {
Mdc3Theme {
content()
}
}

View File

@@ -1,48 +1,48 @@
package com.yogeshpaliyal.keypass.utils
import android.util.Log
import com.yogeshpaliyal.keypass.BuildConfig
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 26-12-2020 20:21
*/
@JvmName("LogHelper")
fun Any?.systemOutPrint() {
if (BuildConfig.DEBUG) println(this)
}
fun Any?.systemErrPrint() {
if (BuildConfig.DEBUG) System.err.println(this)
}
fun Exception?.debugPrintStackTrace() {
if (BuildConfig.DEBUG) this?.printStackTrace()
}
fun Throwable?.debugPrintStackTrace() {
if (BuildConfig.DEBUG) this?.printStackTrace()
}
fun Any?.logD(tag: String?) {
if (BuildConfig.DEBUG) Log.d(tag, this.toString())
}
fun Any?.logE(tag: String?) {
if (BuildConfig.DEBUG) Log.e(tag, this.toString())
}
fun Any?.logI(tag: String?) {
if (BuildConfig.DEBUG) Log.i(tag, this.toString())
}
fun Any?.logV(tag: String?) {
if (BuildConfig.DEBUG) Log.v(tag, this.toString())
}
fun Any?.logW(tag: String?) {
if (BuildConfig.DEBUG) Log.w(tag, this.toString())
}
package com.yogeshpaliyal.keypass.utils
import android.util.Log
import com.yogeshpaliyal.keypass.BuildConfig
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 26-12-2020 20:21
*/
@JvmName("LogHelper")
fun Any?.systemOutPrint() {
if (BuildConfig.DEBUG) println(this)
}
fun Any?.systemErrPrint() {
if (BuildConfig.DEBUG) System.err.println(this)
}
fun Exception?.debugPrintStackTrace() {
if (BuildConfig.DEBUG) this?.printStackTrace()
}
fun Throwable?.debugPrintStackTrace() {
if (BuildConfig.DEBUG) this?.printStackTrace()
}
fun Any?.logD(tag: String?) {
if (BuildConfig.DEBUG) Log.d(tag, this.toString())
}
fun Any?.logE(tag: String?) {
if (BuildConfig.DEBUG) Log.e(tag, this.toString())
}
fun Any?.logI(tag: String?) {
if (BuildConfig.DEBUG) Log.i(tag, this.toString())
}
fun Any?.logV(tag: String?) {
if (BuildConfig.DEBUG) Log.v(tag, this.toString())
}
fun Any?.logW(tag: String?) {
if (BuildConfig.DEBUG) Log.w(tag, this.toString())
}

View File

@@ -1,12 +1,12 @@
package com.yogeshpaliyal.keypass.utils
import androidx.recyclerview.widget.DiffUtil
/**
* Alias to represent a folder (a String title) into which emails can be placed.
*/
object StringDiffUtil : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String) = oldItem == newItem
override fun areContentsTheSame(oldItem: String, newItem: String) = oldItem == newItem
}
package com.yogeshpaliyal.keypass.utils
import androidx.recyclerview.widget.DiffUtil
/**
* Alias to represent a folder (a String title) into which emails can be placed.
*/
object StringDiffUtil : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String) = oldItem == newItem
override fun areContentsTheSame(oldItem: String, newItem: String) = oldItem == newItem
}

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="com.yogeshpaliyal.keypass.ui.settings.MySettingsFragment" />
</layout>

View File

@@ -89,5 +89,6 @@
<string name="confirm_password">Confirm Password</string>
<string name="incorrect_password">Incorrect Password</string>
<string name="password_no_match">The password did not match</string>
<string name="auto_backup_desc">Your accounts will be backed up whenever account is added or modified</string>
</resources>