From 4684a5c23a68add3def22891381215ebcbf2027b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Karpi=C5=84ski?= Date: Sat, 19 Oct 2024 18:06:29 +0200 Subject: [PATCH] Revert "Initial ui rewrite" This reverts commit 2b2933de46e81e23771c4b81696830a20b8a382a. --- app/build.gradle.kts | 45 +- .../medzik/librepass/android/MainActivity.kt | 64 +- .../medzik/librepass/android/ui/Navigation.kt | 129 ++++ .../android/ui/components/CipherCard.kt | 209 +++++++ .../android/ui/components/CipherEditFields.kt | 591 ++++++++++++++++++ .../android/ui/components/CipherTypeDialog.kt | 44 ++ .../android/ui/components/QrScanner.kt | 43 ++ .../android/ui/components/TextInputField.kt | 144 +++++ .../android/ui/components/TopAppBar.kt | 54 ++ .../ui/components/auth/ChoiceServer.kt | 87 +++ .../librepass/android/ui/screens/Welcome.kt | 80 +++ .../ui/screens/auth/AddCustomServer.kt | 121 ++++ .../android/ui/screens/auth/Login.kt | 180 ++++++ .../android/ui/screens/auth/Navigation.kt | 44 ++ .../android/ui/screens/auth/Register.kt | 165 +++++ .../android/ui/screens/auth/Unlock.kt | 201 ++++++ .../android/ui/screens/settings/Navigation.kt | 62 ++ .../android/ui/screens/settings/Settings.kt | 35 ++ .../ui/screens/settings/SettingsAccount.kt | 71 +++ .../ui/screens/settings/SettingsSecurity.kt | 152 +++++ .../screens/settings/account/ChangeEmail.kt | 105 ++++ .../settings/account/ChangePassword.kt | 123 ++++ .../screens/settings/account/DeleteAccount.kt | 91 +++ .../ui/screens/settings/account/Utils.kt | 26 + .../android/ui/screens/vault/CipherAdd.kt | 174 ++++++ .../android/ui/screens/vault/CipherEdit.kt | 177 ++++++ .../android/ui/screens/vault/CipherView.kt | 511 +++++++++++++++ .../android/ui/screens/vault/Navigation.kt | 51 ++ .../android/ui/screens/vault/OtpConfigure.kt | 265 ++++++++ .../android/ui/screens/vault/Search.kt | 115 ++++ .../android/ui/screens/vault/Vault.kt | 308 +++++++++ .../librepass/android/ui/theme/Color.kt | 0 .../librepass/android/ui/theme/Theme.kt | 13 +- .../medzik/librepass/android/ui/theme/Type.kt | 0 .../librepass/android/utils/Biometric.kt | 75 +++ .../librepass/android/utils/Exception.kt | 64 ++ .../librepass/android/utils/KeyAlias.kt | 7 + .../librepass/android/utils/ShortenName.kt | 12 + app/src/main/res/values-ar/strings.xml | 101 +++ app/src/main/res/values-de/strings.xml | 119 ++++ app/src/main/res/values-hi/strings.xml | 121 ++++ app/src/main/res/values-nb-rNO/strings.xml | 93 +++ app/src/main/res/values-pl/strings.xml | 125 ++++ app/src/main/res/values-tr/strings.xml | 121 ++++ app/src/main/res/values-vi/strings.xml | 45 ++ app/src/main/res/values/strings.xml | 123 +++- build.gradle.kts | 2 - database-logic/build.gradle.kts | 5 - .../2.json | 138 ---- .../3.json | 164 ----- .../android/database/CredentialsDao.kt | 3 + .../android/database/CustomServer.kt | 11 - .../android/database/CustomServerDao.kt | 14 - .../librepass/android/database/Database.kt | 11 +- .../android/database/DatabaseProvider.kt | 9 +- .../android/database/LocalCipherDao.kt | 3 + .../librepass/android/database/Repository.kt | 2 - .../database/datastore/CustomServers.kt | 48 ++ gradle.properties | 1 - gradle/libs.versions.toml | 9 +- settings.gradle.kts | 1 - ui-logic/.gitignore | 1 - ui-logic/build.gradle.kts | 70 --- ui-logic/src/main/AndroidManifest.xml | 3 - .../dev/medzik/librepass/android/ui/Home.kt | 29 - .../medzik/librepass/android/ui/Navigation.kt | 42 -- .../librepass/android/ui/WelcomeScreen.kt | 268 -------- .../librepass/android/ui/auth/AuthGraph.kt | 21 - .../librepass/android/ui/auth/ChoiceServer.kt | 215 ------- .../android/ui/auth/ForgotPasswordScreen.kt | 128 ---- .../ui/auth/ForgotPasswordViewModel.kt | 35 -- .../librepass/android/ui/auth/LoginScreen.kt | 141 ----- .../android/ui/auth/LoginViewModel.kt | 60 -- .../librepass/android/ui/auth/SignupScreen.kt | 175 ------ .../android/ui/auth/SignupViewModel.kt | 54 -- .../librepass/android/ui/auth/TextFields.kt | 38 -- .../android/ui/vault/VaultHomeScreen.kt | 17 - ui-logic/src/main/res/values-pl/strings.xml | 30 - ui-logic/src/main/res/values/strings.xml | 30 - .../android/ui/HomePreviewScreenshot.kt | 26 - .../android/ui/auth/LoginPreviewScreenshot.kt | 26 - .../ui/auth/SignupPreviewScreenshot.kt | 26 - 82 files changed, 5521 insertions(+), 1821 deletions(-) create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/CipherCard.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/CipherEditFields.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/CipherTypeDialog.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/QrScanner.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/TextInputField.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/components/auth/ChoiceServer.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/Welcome.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/AddCustomServer.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Login.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Navigation.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Register.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Unlock.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Navigation.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Settings.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangeEmail.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangePassword.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/DeleteAccount.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/Utils.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherAdd.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherEdit.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Navigation.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/OtpConfigure.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Search.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Vault.kt rename {ui-logic => app}/src/main/java/dev/medzik/librepass/android/ui/theme/Color.kt (100%) rename {ui-logic => app}/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt (93%) rename {ui-logic => app}/src/main/java/dev/medzik/librepass/android/ui/theme/Type.kt (100%) create mode 100644 app/src/main/java/dev/medzik/librepass/android/utils/Biometric.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/utils/Exception.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/utils/KeyAlias.kt create mode 100644 app/src/main/java/dev/medzik/librepass/android/utils/ShortenName.kt create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-hi/strings.xml create mode 100644 app/src/main/res/values-nb-rNO/strings.xml create mode 100644 app/src/main/res/values-pl/strings.xml create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 app/src/main/res/values-vi/strings.xml delete mode 100644 database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/2.json delete mode 100644 database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/3.json delete mode 100644 database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServer.kt delete mode 100644 database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServerDao.kt create mode 100644 database-logic/src/main/java/dev/medzik/librepass/android/database/datastore/CustomServers.kt delete mode 100644 ui-logic/.gitignore delete mode 100644 ui-logic/build.gradle.kts delete mode 100644 ui-logic/src/main/AndroidManifest.xml delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/Home.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/WelcomeScreen.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/AuthGraph.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ChoiceServer.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordScreen.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordViewModel.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginScreen.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginViewModel.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupScreen.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupViewModel.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/TextFields.kt delete mode 100644 ui-logic/src/main/java/dev/medzik/librepass/android/ui/vault/VaultHomeScreen.kt delete mode 100644 ui-logic/src/main/res/values-pl/strings.xml delete mode 100644 ui-logic/src/main/res/values/strings.xml delete mode 100644 ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/HomePreviewScreenshot.kt delete mode 100644 ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/LoginPreviewScreenshot.kt delete mode 100644 ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/SignupPreviewScreenshot.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b120b523..9915aa89 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.dagger.hilt) alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.compose.compiler) } @@ -69,14 +71,53 @@ android { } dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + + implementation(libs.compose.material.icons) + implementation(libs.compose.material3) + implementation(libs.compose.navigation) + implementation(libs.compose.ui) + + implementation(libs.accompanist.drawablepainter) + + implementation(libs.androidx.biometric.ktx) + + // used for calling `onResume` and locking vault after X minutes + implementation(libs.compose.lifecycle.runtime) + + implementation(projects.databaseLogic) + // dagger implementation(libs.dagger.hilt) implementation(libs.hilt.navigation.compose) ksp(libs.dagger.hilt.compiler) - implementation(projects.uiLogic) - implementation(projects.databaseLogic) + implementation(libs.coil.compose) + + implementation(libs.librepass.client) + implementation(libs.otp) + + implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.serialization.json) + + implementation(projects.common) + implementation(projects.businessLogic) // for splash screen with material3 and dynamic color implementation(libs.google.material) + + implementation(libs.zxing.android) { isTransitive = false } + implementation(libs.zxing) + + implementation(libs.medzik.android.compose) + implementation(libs.medzik.android.crypto) + implementation(libs.medzik.android.utils) + + // for testing + debugImplementation(libs.compose.ui.test.manifest) + + // for preview support + debugImplementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.tooling.preview) } diff --git a/app/src/main/java/dev/medzik/librepass/android/MainActivity.kt b/app/src/main/java/dev/medzik/librepass/android/MainActivity.kt index 4af8b2a7..97c824f6 100644 --- a/app/src/main/java/dev/medzik/librepass/android/MainActivity.kt +++ b/app/src/main/java/dev/medzik/librepass/android/MainActivity.kt @@ -4,10 +4,16 @@ import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavController import dagger.hilt.android.AndroidEntryPoint +import dev.medzik.android.utils.openEmailApplication +import dev.medzik.librepass.android.business.VaultCache +import dev.medzik.librepass.android.common.popUpToDestination import dev.medzik.librepass.android.database.Repository import dev.medzik.librepass.android.ui.LibrePassNavigation +import dev.medzik.librepass.android.ui.screens.auth.Unlock import dev.medzik.librepass.android.ui.theme.LibrePassTheme import javax.inject.Inject @@ -16,6 +22,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var repository: Repository + @Inject + lateinit var vault: VaultCache + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -25,7 +34,11 @@ class MainActivity : FragmentActivity() { Thread.setDefaultUncaughtExceptionHandler { _, e -> Log.e("LibrePass", "Uncaught exception", e) - // TODO + openEmailApplication( + email = "contact@librepass.org", + subject = "[Bug] [Android]: ", + body = "\n\n\n---- Stack trace for debugging ----\n\n${Log.getStackTraceString(e)}" + ) finish() } @@ -33,34 +46,37 @@ class MainActivity : FragmentActivity() { MigrationsManager.run(this, repository) // retrieves aes key for vault decryption if key is valid -// vault.getSecretsIfNotExpired(this) + vault.getSecretsIfNotExpired(this) setContent { - LibrePassTheme { + LibrePassTheme( + darkTheme = isSystemInDarkTheme(), + dynamicColor = true + ) { LibrePassNavigation() } } } -// override fun onPause() { -// super.onPause() -// -// // check if user is logged -// if (repository.credentials.get() == null) return -// -// vault.saveVaultExpiration(this) -// } -// -// /** Called from [LibrePassNavigation]. */ -// fun onResume(navController: NavController) { -// // check if user is logged -// if (repository.credentials.get() == null) return -// -// val expired = vault.handleExpiration(this) -// if (expired) { -// navController.navigate(Unlock) { -// popUpToDestination(Unlock) -// } -// } -// } + override fun onPause() { + super.onPause() + + // check if user is logged + if (repository.credentials.get() == null) return + + vault.saveVaultExpiration(this) + } + + /** Called from [LibrePassNavigation]. */ + fun onResume(navController: NavController) { + // check if user is logged + if (repository.credentials.get() == null) return + + val expired = vault.handleExpiration(this) + if (expired) { + navController.navigate(Unlock) { + popUpToDestination(Unlock) + } + } + } } diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt b/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt new file mode 100644 index 00000000..6a0eb3f8 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt @@ -0,0 +1,129 @@ +package dev.medzik.librepass.android.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.medzik.android.compose.icons.TopAppBarBackIcon +import dev.medzik.android.compose.navigation.NavigationAnimations +import dev.medzik.librepass.android.MainActivity +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.ui.components.TopBar +import dev.medzik.librepass.android.ui.screens.Welcome +import dev.medzik.librepass.android.ui.screens.WelcomeScreen +import dev.medzik.librepass.android.ui.screens.auth.Unlock +import dev.medzik.librepass.android.ui.screens.auth.authNavigation +import dev.medzik.librepass.android.ui.screens.settings.settingsNavigation +import dev.medzik.librepass.android.ui.screens.vault.Vault +import dev.medzik.librepass.android.ui.screens.vault.vaultNavigation + +@Composable +fun LibrePassNavigation(viewModel: LibrePassViewModel = hiltViewModel()) { + val context = LocalContext.current + val navController = rememberNavController() + + // Lifecycle events handler. + // This calls the `onResume` function from MainActivity when the application is resumed. + // This is used to lock the vault after X minutes of application sleep in memory. + val lifecycleOwner = LocalLifecycleOwner.current + val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState() + LaunchedEffect(lifecycleState) { + when (lifecycleState) { + // when the application was resumed + Lifecycle.State.RESUMED -> { + // calls the `onResume` function from MainActivity + (context as MainActivity).onResume(navController) + } + // ignore any other lifecycle state + else -> {} + } + } + + fun getStartRoute(): Any { + // if a user is not logged in, show welcome screen + viewModel.credentialRepository.get() ?: return Welcome + + // if user secrets are not set, show unlock screen + if (viewModel.vault.aesKey.isEmpty()) + return Unlock + + // else where the user secrets are set, show vault screen + return Vault + } + + NavHost( + navController, + startDestination = remember { getStartRoute() }, + modifier = Modifier.imePadding(), + enterTransition = { + NavigationAnimations.enterTransition() + }, + exitTransition = { + NavigationAnimations.exitTransition() + }, + popEnterTransition = { + NavigationAnimations.popEnterTransition() + }, + popExitTransition = { + NavigationAnimations.popExitTransition() + } + ) { + composable { + WelcomeScreen(navController) + } + + authNavigation(navController) + + vaultNavigation(navController) + + settingsNavigation(navController) + } +} + +@Composable +fun DefaultScaffold( + topBar: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + horizontalPadding: Boolean = true, + composable: @Composable () -> Unit +) { + Scaffold( + topBar = { topBar() }, + floatingActionButton = { floatingActionButton() } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = if (horizontalPadding) 16.dp else 0.dp) + ) { + composable() + } + } +} + +@Composable +fun TopBarWithBack(@StringRes title: Int, navController: NavController) { + TopBar( + title = stringResource(title), + navigationIcon = { TopAppBarBackIcon(navController) } + ) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherCard.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherCard.kt new file mode 100644 index 00000000..2efd4917 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherCard.kt @@ -0,0 +1,209 @@ +package dev.medzik.librepass.android.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Card +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import dev.medzik.android.compose.ui.dialog.DialogState +import dev.medzik.android.compose.ui.dialog.PickerDialog +import dev.medzik.android.compose.ui.dialog.rememberDialogState +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.utils.SHORTEN_NAME_LENGTH +import dev.medzik.librepass.android.utils.SHORTEN_USERNAME_LENGTH +import dev.medzik.librepass.android.utils.shorten +import dev.medzik.librepass.client.api.CipherClient +import dev.medzik.librepass.types.cipher.Cipher +import dev.medzik.librepass.types.cipher.CipherType + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CipherCard( + cipher: Cipher, + onClick: (Cipher) -> Unit, + onEdit: (Cipher) -> Unit, + onDelete: (Cipher) -> Unit, + showCipherActions: Boolean = true +) { + val dialogState = rememberDialogState() + + fun showMoreOptions() = dialogState.show() + + fun getDomain(): String? { + val uris = cipher.loginData?.uris + return if (!uris.isNullOrEmpty()) uris[0] else null + } + + @Composable + fun CipherIcon() { + when (cipher.type) { + CipherType.Login -> { + val domain = getDomain() + if (domain != null) { + AsyncImage( + // TODO: custom api url + model = CipherClient.getFavicon(domain = domain), + contentDescription = null, + error = rememberVectorPainter(Icons.Default.Person), + modifier = Modifier.size(24.dp) + ) + } else { + Image( + Icons.Default.Person, + contentDescription = null, + ) + } + } + + CipherType.SecureNote -> { + Image( + Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + ) + } + + CipherType.Card -> { + Image( + Icons.Default.CreditCard, + contentDescription = null, + ) + } + } + } + + @Composable + fun CipherText() { + val title: String + var subtitle: String? = null + + when (cipher.type) { + CipherType.Login -> { + title = cipher.loginData!!.name + + if (!cipher.loginData!!.email.isNullOrEmpty()) { + subtitle = cipher.loginData!!.email + } else if (!cipher.loginData!!.username.isNullOrEmpty()) { + subtitle = cipher.loginData!!.username + } + } + + CipherType.SecureNote -> { + title = cipher.secureNoteData!!.title + } + + CipherType.Card -> { + title = cipher.cardData!!.name + subtitle = "•••• " + cipher.cardData!!.number.takeLast(4) + } + } + + Text( + text = title.shorten(SHORTEN_NAME_LENGTH), + style = MaterialTheme.typography.titleMedium + ) + + if (subtitle != null) { + Text( + text = subtitle.shorten(SHORTEN_USERNAME_LENGTH), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + Card( + modifier = Modifier.padding(vertical = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onClick(cipher) }, + onLongClick = { showMoreOptions() } + ) + .heightIn(min = 64.dp) + .padding(horizontal = 24.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CipherIcon() + + Column( + modifier = Modifier + .padding(start = 16.dp) + .fillMaxSize() + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + CipherText() + } + + if (showCipherActions) { + IconButton(onClick = { showMoreOptions() }) { + Icon(Icons.Default.MoreHoriz, contentDescription = null) + } + + CipherActionsDialog( + state = dialogState, + onClick = { onClick(cipher) }, + onEdit = { onEdit(cipher) }, + onDelete = { onDelete(cipher) } + ) + } + } + } +} + +@Composable +fun CipherActionsDialog( + state: DialogState, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + PickerDialog( + state, + title = null, + items = listOf( + R.string.View, + R.string.Edit, + R.string.Delete + ), + onSelected = { + when (it) { + R.string.View -> onClick() + R.string.Edit -> onEdit() + R.string.Delete -> onDelete() + } + } + ) { + Text( + text = stringResource(it), + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherEditFields.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherEditFields.kt new file mode 100644 index 00000000..49f87a60 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherEditFields.kt @@ -0,0 +1,591 @@ +package dev.medzik.librepass.android.ui.components + +import android.util.Log +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.EuroSymbol +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.Title +import androidx.compose.material.icons.filled.Web +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.gson.Gson +import dev.medzik.android.compose.colorizePasswordTransformation +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.GroupTitle +import dev.medzik.android.compose.ui.IconBox +import dev.medzik.android.compose.ui.bottomsheet.BaseBottomSheet +import dev.medzik.android.compose.ui.bottomsheet.rememberBottomSheetState +import dev.medzik.android.compose.ui.preference.SwitcherPreference +import dev.medzik.android.compose.ui.textfield.AnimatedTextField +import dev.medzik.android.compose.ui.textfield.PasswordAnimatedTextField +import dev.medzik.android.compose.ui.textfield.TextFieldValue +import dev.medzik.android.utils.runOnIOThread +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.database.datastore.PasswordGeneratorPreference +import dev.medzik.librepass.android.database.datastore.readPasswordGeneratorPreference +import dev.medzik.librepass.android.database.datastore.writePasswordGeneratorPreference +import dev.medzik.librepass.android.ui.screens.vault.OtpConfigure +import dev.medzik.librepass.types.cipher.Cipher +import dev.medzik.librepass.types.cipher.data.CipherLoginData +import kotlinx.coroutines.launch +import java.util.Random + +enum class PasswordType(val literals: String) { + NUMERIC("1234567890"), + LOWERCASE("abcdefghijklmnopqrstuvwxyz"), + UPPERCASE("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + SYMBOLS("!@#\$%^&*()_+-=[]{}\\|;:'\",.<>/?") +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CipherEditFieldsLogin( + navController: NavController, + cipher: Cipher, + button: @Composable (cipher: Cipher) -> Unit +) { + val scope = rememberCoroutineScope() + + var cipherData by rememberMutable(cipher.loginData!!) + + // observe otp uri from navController + // used to get otp uri from otp configuration screen + navController + .currentBackStackEntry + ?.savedStateHandle + ?.getLiveData("otpUri")?.observeForever { + Log.d("OTP_OBSERVABLE", "Received URI: $it") + cipherData = cipherData.copy(twoFactor = it) + } + // observe for cipher from backstack + navController + .currentBackStackEntry + ?.savedStateHandle + ?.getLiveData("cipher")?.observeForever { + val currentPassword = cipherData.password + + val newCipherData = Gson().fromJson(it, CipherLoginData::class.java) + cipherData = newCipherData.copy(password = currentPassword) + } + + AnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + label = stringResource(R.string.Name), + value = TextFieldValue( + value = cipherData.name, + onChange = { cipherData = cipherData.copy(name = it) } + ), + leading = { + IconBox(Icons.Default.AccountCircle) + } + ) + + GroupTitle( + stringResource(R.string.LoginDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + AnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + label = stringResource(R.string.Email), + value = TextFieldValue( + value = cipherData.email ?: "", + onChange = { cipherData = cipherData.copy(email = it) } + ), + leading = { + IconBox(Icons.Default.Email) + } + ) + + AnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + label = stringResource(R.string.Username), + value = TextFieldValue( + value = cipherData.username ?: "", + onChange = { cipherData = cipherData.copy(username = it) } + ), + leading = { + IconBox(Icons.Default.Badge) + } + ) + + val passwordGeneratorSheetState = rememberBottomSheetState() + + PasswordAnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + label = stringResource(R.string.Password), + value = TextFieldValue( + value = cipherData.username ?: "", + onChange = { cipherData = cipherData.copy(username = it) } + ), + trailing = { + IconButton( + onClick = { passwordGeneratorSheetState.show() } + ) { + IconBox(Icons.Default.AutoAwesome) + } + } + ) + + @Composable + fun PasswordGeneratorSheetContent(onSubmit: (String) -> Unit) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + var generatedPassword by rememberMutable("") + + var passwordGeneratorPreference by rememberMutable(PasswordGeneratorPreference()) + LaunchedEffect(Unit) { + passwordGeneratorPreference = readPasswordGeneratorPreference(context) + } + + fun generatePassword(): String { + var letters = PasswordType.LOWERCASE.literals + + if (passwordGeneratorPreference.capitalize) { + letters += PasswordType.UPPERCASE.literals + } + + if (passwordGeneratorPreference.includeNumbers) { + letters += PasswordType.NUMERIC.literals + } + + if (passwordGeneratorPreference.includeSymbols) { + letters += PasswordType.SYMBOLS.literals + } + + return (1..passwordGeneratorPreference.length) + .map { Random().nextInt(letters.length) } + .map(letters::get) + .joinToString("") + } + + // regenerate on options change + LaunchedEffect(passwordGeneratorPreference) { + generatedPassword = generatePassword() + } + + AnimatedTextField( + modifier = Modifier.padding(horizontal = 8.dp), + value = TextFieldValue( + value = generatedPassword, + editable = false + ), + visualTransformation = colorizePasswordTransformation(), + trailing = { + Row { + IconButton( + onClick = { clipboardManager.setText(AnnotatedString(generatedPassword)) } + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null + ) + } + + IconButton( + onClick = { generatedPassword = generatePassword() } + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null + ) + } + } + } + ) + + Spacer( + modifier = Modifier.padding(top = 8.dp) + ) + + AnimatedTextField( + modifier = Modifier.padding(horizontal = 8.dp), + value = TextFieldValue( + value = passwordGeneratorPreference.length.toString(), + onChange = { + try { + if (it.length in 1..3 && it.toInt() <= 256) { + passwordGeneratorPreference = passwordGeneratorPreference.copy(length = it.toInt()) + runOnIOThread { writePasswordGeneratorPreference(context, passwordGeneratorPreference) } + } + } catch (e: NumberFormatException) { + // ignore, just do not update input value + } + } + ), + label = stringResource(R.string.PasswordGenerator_Length), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + trailing = { + IconButton( + onClick = { + if (passwordGeneratorPreference.length > 1) { + passwordGeneratorPreference = passwordGeneratorPreference.copy( + length = passwordGeneratorPreference.length - 1 + ) + runOnIOThread { writePasswordGeneratorPreference(context, passwordGeneratorPreference) } + } + } + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = null + ) + } + + IconButton( + onClick = { + if (passwordGeneratorPreference.length < 256) { + passwordGeneratorPreference = passwordGeneratorPreference.copy( + length = passwordGeneratorPreference.length + 1 + ) + runOnIOThread { writePasswordGeneratorPreference(context, passwordGeneratorPreference) } + } + } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + } + } + ) + + SwitcherPreference( + title = stringResource(R.string.PasswordGenerator_CapitalLetters), + checked = passwordGeneratorPreference.capitalize, + onCheckedChange = { + passwordGeneratorPreference = passwordGeneratorPreference.copy(capitalize = it) + runOnIOThread { writePasswordGeneratorPreference(context, passwordGeneratorPreference) } + }, + leading = { + IconBox(Icons.Default.Title) + } + ) + + SwitcherPreference( + title = stringResource(R.string.PasswordGenerator_Numbers), + checked = passwordGeneratorPreference.includeNumbers, + onCheckedChange = { + passwordGeneratorPreference = passwordGeneratorPreference.copy(includeNumbers = it) + runOnIOThread { writePasswordGeneratorPreference(context, passwordGeneratorPreference) } + }, + leading = { + IconBox(Icons.Default.Numbers) + } + ) + + SwitcherPreference( + title = stringResource(R.string.PasswordGenerator_Symbols), + checked = passwordGeneratorPreference.includeSymbols, + onCheckedChange = { + passwordGeneratorPreference = passwordGeneratorPreference.copy(includeSymbols = it) + runOnIOThread { writePasswordGeneratorPreference(context, passwordGeneratorPreference) } + }, + leading = { + IconBox(Icons.Default.EuroSymbol) + } + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 90.dp) + .padding(top = 16.dp, bottom = 8.dp), + onClick = { onSubmit(generatedPassword) } + ) { + Text(stringResource(R.string.Submit)) + } + } + + BaseBottomSheet( + state = passwordGeneratorSheetState, + onDismiss = { + scope.launch { passwordGeneratorSheetState.hide() } + } + ) { + PasswordGeneratorSheetContent( + onSubmit = { + cipherData = cipherData.copy(password = it) + + scope.launch { + passwordGeneratorSheetState.hide() + } + } + ) + } + + GroupTitle( + stringResource(R.string.WebsiteDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + // show field for each uri + cipherData.uris?.forEachIndexed { index, uri -> + AnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + label = stringResource(R.string.WebsiteAddress) + " ${index + 1}", + value = TextFieldValue( + value = uri, + onChange = { + cipherData = cipherData.copy( + uris = cipherData.uris.orEmpty().toMutableList().apply { + this[index] = it + } + ) + } + ), + leading = { + IconBox(Icons.Default.Web) + }, + trailing = { + IconButton( + onClick = { + cipherData = cipherData.copy( + uris = cipherData.uris.orEmpty().toMutableList().apply { + this.removeAt(index) + } + ) + } + ) { + IconBox(Icons.Default.Delete) + } + } + ) + } + + // button for adding more fields + Button( + onClick = { + cipherData = cipherData.copy( + uris = cipherData.uris.orEmpty() + "" + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.AddField)) + } + + GroupTitle( + stringResource(R.string.TwoFactorAuthentication), + modifier = Modifier.padding(top = 8.dp) + ) + + Button( + onClick = { + navController.navigate( + OtpConfigure( + cipher.id.toString() + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.ConfigureTwoFactor)) + } + + if (!cipher.loginData?.twoFactor.isNullOrEmpty()) { + Button( + onClick = { cipherData = cipherData.copy(twoFactor = null) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.DeleteTwoFactor)) + } + } + + GroupTitle( + stringResource(R.string.OtherDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + AnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + label = stringResource(R.string.Notes), + value = TextFieldValue( + value = cipherData.notes ?: "", + onChange = { cipherData = cipherData.copy(notes = it) } + ), + leading = { + IconBox(Icons.Default.Badge) + } + ) + + button(cipher.copy(loginData = cipherData)) +} + +@Composable +fun CipherEditFieldsSecureNote( + cipher: Cipher, + button: @Composable (cipher: Cipher) -> Unit +) { + var cipherData by rememberMutable(cipher.secureNoteData!!) + + TextInputFieldBase( + label = stringResource(R.string.Title), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.title, + onValueChange = { cipherData = cipherData.copy(title = it) } + ) + + TextInputFieldBase( + label = stringResource(R.string.Notes), + modifier = Modifier.fillMaxWidth(), + singleLine = false, + value = cipherData.note, + onValueChange = { cipherData = cipherData.copy(note = it) } + ) + + button(cipher.copy(secureNoteData = cipherData)) +} + +@Composable +fun CipherEditFieldsCard( + cipher: Cipher, + button: @Composable (cipher: Cipher) -> Unit +) { + var cipherData by rememberMutable(cipher.cardData!!) + + TextInputFieldBase( + label = stringResource(R.string.Name), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.name, + onValueChange = { cipherData = cipherData.copy(name = it) } + ) + + TextInputFieldBase( + label = stringResource(R.string.CardholderName), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.cardholderName, + onValueChange = { cipherData = cipherData.copy(cardholderName = it) } + ) + + TextInputFieldBase( + label = stringResource(R.string.CardNumber), + keyboardType = KeyboardType.Number, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.number, + onValueChange = { + if (!it.all { char -> char.isDigit() }) + return@TextInputFieldBase + + cipherData = cipherData.copy(number = it) + } + ) + + TextInputFieldBase( + label = stringResource(R.string.ExpirationMonth), + keyboardType = KeyboardType.Number, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.expMonth, + onValueChange = { + if (it.isEmpty()) { + cipherData = cipherData.copy(expMonth = null) + return@TextInputFieldBase + } + + if (!it.all { char -> char.isDigit() } || it.length > 2 || it.toInt() > 12) + return@TextInputFieldBase + + cipherData = cipherData.copy(expMonth = it) + } + ) + + TextInputFieldBase( + label = stringResource(R.string.ExpirationYear), + keyboardType = KeyboardType.Number, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.expYear, + onValueChange = { + if (it.isEmpty()) { + cipherData = cipherData.copy(expYear = null) + return@TextInputFieldBase + } + if (!it.all { char -> char.isDigit() } || it.length > 4) + return@TextInputFieldBase + + cipherData = cipherData.copy(expYear = it) + } + ) + + TextInputFieldBase( + label = stringResource(R.string.SecurityCode), + keyboardType = KeyboardType.Number, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + value = cipherData.code, + onValueChange = { + if (!it.all { char -> char.isDigit() }) + return@TextInputFieldBase + + cipherData = cipherData.copy(code = it) + } + ) + + GroupTitle( + stringResource(R.string.OtherDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + TextInputFieldBase( + label = stringResource(R.string.Notes), + modifier = Modifier.fillMaxWidth(), + singleLine = false, + value = cipherData.notes, + onValueChange = { cipherData = cipherData.copy(notes = it) } + ) + + button(cipher.copy(cardData = cipherData)) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherTypeDialog.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherTypeDialog.kt new file mode 100644 index 00000000..39d6916d --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/CipherTypeDialog.kt @@ -0,0 +1,44 @@ +package dev.medzik.librepass.android.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.unit.dp +import dev.medzik.android.compose.ui.dialog.DialogState +import dev.medzik.android.compose.ui.dialog.PickerDialog +import dev.medzik.librepass.android.R +import dev.medzik.librepass.types.cipher.CipherType + +@Composable +fun CipherTypeDialog( + state: DialogState, + onSelected: (CipherType) -> Unit +) { + @Composable + fun getTranslated(type: CipherType): String { + return stringResource( + when (type) { + CipherType.Login -> R.string.CipherType_Login + CipherType.SecureNote -> R.string.CipherType_SecureNote + CipherType.Card -> R.string.CipherType_Card + } + ) + } + + PickerDialog( + state, + title = stringResource(R.string.SelectCipherType), + items = CipherType.entries, + onSelected + ) { + Text( + text = getTranslated(it), + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/QrScanner.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/QrScanner.kt new file mode 100644 index 00000000..ac92d81f --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/QrScanner.kt @@ -0,0 +1,43 @@ +package dev.medzik.librepass.android.ui.components + +import android.app.Activity +import android.util.Log +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.journeyapps.barcodescanner.CaptureManager +import com.journeyapps.barcodescanner.DecoratedBarcodeView + +@Composable +fun QrCodeScanner(onScanned: (String) -> Unit) { + val context = LocalContext.current + + val compoundBarcodeView = + remember { + DecoratedBarcodeView(context).apply { + val capture = CaptureManager(context as Activity, this) + capture.initializeFromIntent(context.intent, null) + this.setStatusText("") + this.resume() + capture.decode() + this.decodeContinuous { result -> + result.text?.let { scannedText -> + Log.i("QR_SCANNER", scannedText) + onScanned.invoke(scannedText) + } + } + } + } + + AndroidView( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + factory = { compoundBarcodeView } + ) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/TextInputField.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/TextInputField.kt new file mode 100644 index 00000000..7c194e5d --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/TextInputField.kt @@ -0,0 +1,144 @@ +package dev.medzik.librepass.android.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation + +@Composable +fun TextInputField( + label: String, + hidden: Boolean = false, + value: String?, + onValueChange: (String) -> Unit, + emptySupportingText: Boolean = false, + isError: Boolean = false, + errorMessage: String? = null, + keyboardType: KeyboardType = KeyboardType.Text +) { + val hiddenState = remember { mutableStateOf(hidden) } + + var supportingText: @Composable (() -> Unit)? = null + + if (errorMessage != null) { + supportingText = if (isError) { + { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error + ) + } + } else { + { Text(text = "") } + } + } + + if (emptySupportingText) { + supportingText = { Text(text = "") } + } + + OutlinedTextField( + value = value ?: "", + onValueChange = onValueChange, + label = { Text(label) }, + maxLines = 1, + singleLine = true, + visualTransformation = ( + if (hidden && hiddenState.value) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } + ), + trailingIcon = { + if (hidden) { + IconButton(onClick = { hiddenState.value = !hiddenState.value }) { + Icon( + imageVector = ( + if (hiddenState.value) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + } + ), + contentDescription = null + ) + } + } + }, + supportingText = supportingText, + isError = isError, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType +), + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +fun TextInputFieldBase( + label: String, + modifier: Modifier = Modifier, + hidden: Boolean = false, + value: String?, + isError: Boolean = false, + onValueChange: (String) -> Unit, + keyboardType: KeyboardType = KeyboardType.Text, + singleLine: Boolean = true, + trailingIcon: @Composable () -> Unit = {} +) { + val hiddenState = remember { mutableStateOf(hidden) } + + OutlinedTextField( + value = value ?: "", + onValueChange = onValueChange, + isError = isError, + label = { Text(label) }, + singleLine = singleLine, + visualTransformation = ( + if (hidden && hiddenState.value) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } + ), + trailingIcon = { + Row { + if (hidden) { + IconButton(onClick = { hiddenState.value = !hiddenState.value }) { + Icon( + imageVector = ( + if (hiddenState.value) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + } + ), + contentDescription = null + ) + } + } + + trailingIcon() + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType + ), + modifier = modifier + ) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt new file mode 100644 index 00000000..e047deea --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/TopAppBar.kt @@ -0,0 +1,54 @@ +package dev.medzik.librepass.android.ui.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavController +import dev.medzik.android.compose.icons.TopAppBarBackIcon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopBar( + title: String, + navigationIcon: @Composable (() -> Unit) = {}, + actions: @Composable (RowScope.() -> Unit) = {} +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = navigationIcon, + actions = actions + ) +} + +@Preview +@Composable +fun TopBarPreview() { + TopBar( + title = "Title", + navigationIcon = { + TopAppBarBackIcon(navController = NavController(LocalContext.current)) + }, + actions = { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.MoreHoriz, + contentDescription = null + ) + } + } + ) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/components/auth/ChoiceServer.kt b/app/src/main/java/dev/medzik/librepass/android/ui/components/auth/ChoiceServer.kt new file mode 100644 index 00000000..1775a1cb --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/components/auth/ChoiceServer.kt @@ -0,0 +1,87 @@ +package dev.medzik.librepass.android.ui.components.auth + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import dev.medzik.android.compose.ui.dialog.PickerDialog +import dev.medzik.android.compose.ui.dialog.rememberDialogState +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.database.datastore.CustomServers +import dev.medzik.librepass.android.database.datastore.readCustomServers +import dev.medzik.librepass.android.ui.screens.auth.AddCustomServer +import dev.medzik.librepass.client.Server +import kotlinx.coroutines.runBlocking + +@Composable +fun ChoiceServer(navController: NavController, server: MutableState) { + val serverChoiceDialog = rememberDialogState() + + @Composable + fun getServerName(server: String): String { + return when (server) { + Server.PRODUCTION -> { + stringResource(R.string.Server_Official) + } + else -> server + } + } + + Row( + modifier = Modifier + .padding(vertical = 8.dp) + .clickable { serverChoiceDialog.show() } + ) { + Text( + text = stringResource(R.string.ServerAddress) + ": ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + + Text( + text = getServerName(server.value), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + val context = LocalContext.current + + val servers = listOf( + CustomServers( + name = stringResource(R.string.Server_Official), + address = Server.PRODUCTION + ) + ) + .plus(runBlocking { readCustomServers(context) }) + .plus(CustomServers(stringResource(R.string.Server_Choice_Dialog_AddCustom), "custom_server")) + + PickerDialog( + state = serverChoiceDialog, + title = stringResource(R.string.ServerAddress), + items = servers, + onSelected = { + if (it.address == "custom_server") { + navController.navigate(AddCustomServer) + } else { + server.value = it.address + } + } + ) { + Text( + text = it.name, + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/Welcome.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/Welcome.kt new file mode 100644 index 00000000..189e0b4e --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/Welcome.kt @@ -0,0 +1,80 @@ +package dev.medzik.librepass.android.ui.screens + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.accompanist.drawablepainter.DrawablePainter +import dev.medzik.android.compose.ui.TopAppBarMultiColor +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.ui.screens.auth.Login +import dev.medzik.librepass.android.ui.screens.auth.Register +import kotlinx.serialization.Serializable + +@Serializable +object Welcome + +@Composable +fun WelcomeScreen(navController: NavController) { + val context = LocalContext.current + + // get app icon + val icon = context.packageManager.getApplicationIcon(context.packageName) + + Scaffold( + topBar = { TopAppBarMultiColor(firstText = "Libre", secondText = "Pass") } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = DrawablePainter(icon), + contentDescription = null, + modifier = Modifier.size(128.dp) + ) + + Text( + text = stringResource(R.string.WelcomeScreen_Title), + modifier = Modifier.padding(top = 20.dp) + ) + + Button( + onClick = { navController.navigate(Register) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 90.dp) + .padding(top = 20.dp) + ) { + Text(stringResource(R.string.Register)) + } + + OutlinedButton( + onClick = { navController.navigate(Login) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 90.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.Login)) + } + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/AddCustomServer.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/AddCustomServer.kt new file mode 100644 index 00000000..d8e117ff --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/AddCustomServer.kt @@ -0,0 +1,121 @@ +package dev.medzik.librepass.android.ui.screens.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.google.gson.JsonSyntaxException +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.compose.ui.textfield.AnimatedTextField +import dev.medzik.android.compose.ui.textfield.TextFieldValue +import dev.medzik.android.utils.runOnIOThread +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.database.datastore.CustomServers +import dev.medzik.librepass.android.database.datastore.readCustomServers +import dev.medzik.librepass.android.database.datastore.writeCustomServers +import dev.medzik.librepass.client.api.checkApiConnection +import kotlinx.serialization.Serializable + +@Serializable +object AddCustomServer + +@Composable +fun AddCustomServerScreen(navController: NavController) { + val context = LocalContext.current + + var loading by rememberMutable(false) + var server by rememberMutable(CustomServers("", "https://")) + + fun submit() { + loading = true + + runOnIOThread { + // TODO: Delete try when released new version of LibrePass client library + try { + if (!checkApiConnection(server.address)) { + context.showToast(R.string.Tost_NoServerConnection) + + loading = false + + return@runOnIOThread + } + } catch (e: JsonSyntaxException) { + context.showToast(R.string.Tost_NoServerConnection) + + loading = false + + return@runOnIOThread + } + + val servers = readCustomServers(context) + writeCustomServers(context, servers.plus(server)) + + runOnUiThread { navController.popBackStack() } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + AnimatedTextField( + label = stringResource(R.string.Name), + value = TextFieldValue( + value = server.name, + onChange = { server = server.copy(name = it) } + ), + leading = { + Icon( + imageVector = Icons.Default.Draw, + contentDescription = null + ) + } + ) + + AnimatedTextField( + label = stringResource(R.string.ServerAddress), + value = TextFieldValue( + value = server.address, + onChange = { server = server.copy(address = it) } + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri + ), + clearButton = true, + leading = { + Icon( + imageVector = Icons.Default.Dns, + contentDescription = null + ) + } + ) + } + + LoadingButton( + loading = loading, + onClick = { submit() }, + enabled = server.name.isNotEmpty() && server.address.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 80.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.Add)) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Login.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Login.kt new file mode 100644 index 00000000..dc6d8368 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Login.kt @@ -0,0 +1,180 @@ +package dev.medzik.librepass.android.ui.screens.auth + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.compose.ui.textfield.AnimatedTextField +import dev.medzik.android.compose.ui.textfield.PasswordAnimatedTextField +import dev.medzik.android.compose.ui.textfield.TextFieldValue +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.haveNetworkConnection +import dev.medzik.librepass.android.common.popUpToStartDestination +import dev.medzik.librepass.android.database.Credentials +import dev.medzik.librepass.android.ui.components.auth.ChoiceServer +import dev.medzik.librepass.android.ui.screens.vault.Vault +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.client.Server +import dev.medzik.librepass.client.api.AuthClient +import dev.medzik.librepass.utils.fromHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object Login + +@Composable +fun LoginScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + + val scope = rememberCoroutineScope() + + var loading by rememberMutable(false) + val email = rememberMutable("") + val password = rememberMutable("") + val server = rememberMutable(Server.PRODUCTION) + + fun submit(email: String, password: String) { + if (!context.haveNetworkConnection()) { + context.showToast(R.string.Error_NoInternetConnection) + return + } + + val authClient = AuthClient(apiUrl = server.value) + + if (email.isEmpty() || password.isEmpty()) + return + + loading = true + + scope.launch(Dispatchers.IO) { + try { + val preLogin = authClient.preLogin(email) + + val credentials = authClient.login( + email = email, + password = password + ) + + // save credentials + val credentialsDb = Credentials( + userId = credentials.userId, + email = email, + apiUrl = if (server.value == Server.PRODUCTION) null else server.value, + apiKey = credentials.apiKey, + publicKey = credentials.publicKey, + // Argon2id parameters + memory = preLogin.memory, + iterations = preLogin.iterations, + parallelism = preLogin.parallelism + ) + viewModel.credentialRepository.insert(credentialsDb) + + viewModel.vault.aesKey = credentials.aesKey.fromHex() + + viewModel.credentialRepository.update( + credentialsDb.copy( + biometricReSetup = true + ) + ) + + runOnUiThread { + navController.navigate(Vault) { + popUpToStartDestination(navController) + } + } + } catch (e: Exception) { + loading = false + e.showErrorToast(context) + } + } + } + + AnimatedTextField( + label = stringResource(R.string.Email), + value = TextFieldValue.fromMutableState(email), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ), + leading = { + Icon( + Icons.Default.Email, + contentDescription = null + ) + } + ) + + fun requestPasswordHint() { + val authClient = AuthClient(apiUrl = server.value) + + if (email.value.isEmpty()) { + context.showToast(context.getString(R.string.Toast_Enter_Email)) + return + } + + scope.launch(Dispatchers.IO) { + try { + authClient.requestPasswordHint(email.value) + + context.showToast(context.getString(R.string.Toast_Password_Hint_Sent)) + } catch (e: Exception) { + e.showErrorToast(context) + } + } + } + + Text( + text = stringResource(R.string.GetPasswordHint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(vertical = 8.dp) + .clickable { requestPasswordHint() } + ) + + PasswordAnimatedTextField( + label = stringResource(R.string.Password), + value = TextFieldValue.fromMutableState(password), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ) + ) + + ChoiceServer(navController, server) + + LoadingButton( + loading = loading, + onClick = { submit(email.value, password.value) }, + enabled = email.value.isNotEmpty() && password.value.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp) + ) { + Text(stringResource(R.string.Login)) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Navigation.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Navigation.kt new file mode 100644 index 00000000..c92d2f73 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Navigation.kt @@ -0,0 +1,44 @@ +package dev.medzik.librepass.android.ui.screens.auth + +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.ui.DefaultScaffold +import dev.medzik.librepass.android.ui.TopBarWithBack +import dev.medzik.librepass.android.ui.components.TopBar + +fun NavGraphBuilder.authNavigation(navController: NavController) { + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.Register, navController) } + ) { + RegisterScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.Login, navController) } + ) { + LoginScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBar(title = stringResource(R.string.Unlock)) } + ) { + UnlockScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.AddServer, navController) } + ) { + AddCustomServerScreen(navController) + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Register.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Register.kt new file mode 100644 index 00000000..dab3268d --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Register.kt @@ -0,0 +1,165 @@ +package dev.medzik.librepass.android.ui.screens.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.compose.ui.textfield.AnimatedTextField +import dev.medzik.android.compose.ui.textfield.PasswordAnimatedTextField +import dev.medzik.android.compose.ui.textfield.TextFieldValue +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.haveNetworkConnection +import dev.medzik.librepass.android.common.popUpToStartDestination +import dev.medzik.librepass.android.ui.components.auth.ChoiceServer +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.client.Server +import dev.medzik.librepass.client.api.AuthClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object Register + +@Composable +fun RegisterScreen(navController: NavController) { + val context = LocalContext.current + + val scope = rememberCoroutineScope() + + var loading by rememberMutable(false) + val email = rememberMutable("") + val password = rememberMutable("") + val confirmPassword = rememberMutable("") + val passwordHint = rememberMutable("") + val server = rememberMutable(Server.PRODUCTION) + + // Register user with given credentials and navigate to log in screen. + fun submit(email: String, password: String, passwordHint: String?) { + if (!context.haveNetworkConnection()) { + context.showToast(R.string.Error_NoInternetConnection) + return + } + + val authClient = AuthClient(apiUrl = server.value) + + // disable button + loading = true + + scope.launch(Dispatchers.IO) { + try { + authClient.register(email, password, passwordHint) + + // navigate to login + runOnUiThread { + context.showToast(R.string.Toast_PleaseVerifyYourEmail) + + navController.navigate(Login) { + popUpToStartDestination(navController) + } + } + } catch (e: Exception) { + loading = false + e.showErrorToast(context) + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + AnimatedTextField( + label = stringResource(R.string.Email), + value = TextFieldValue.fromMutableState(email), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ), + leading = { + Icon( + Icons.Default.Email, + contentDescription = null + ) + } + ) + + PasswordAnimatedTextField( + label = stringResource(R.string.Password), + value = TextFieldValue.fromMutableState( + state = password, + error = if (password.value.isNotEmpty() && password.value.length < 8) { + stringResource(R.string.Error_PasswordTooShort) + } else null + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ) + ) + + PasswordAnimatedTextField( + label = stringResource(R.string.ConfirmPassword), + value = TextFieldValue.fromMutableState( + state = confirmPassword, + error = if (confirmPassword.value.isNotEmpty() && password.value != confirmPassword.value) { + stringResource(R.string.Error_PasswordsDoNotMatch) + } else null + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ) + ) + + AnimatedTextField( + label = stringResource(R.string.PasswordHint), + value = TextFieldValue.fromMutableState( + passwordHint, + valueLabel = TextFieldValue.ValueLabel( + type = TextFieldValue.ValueLabel.Type.INFO, + text = stringResource(R.string.Optional) + ) + ), + leading = { + Icon( + Icons.Default.QuestionMark, + contentDescription = null + ) + } + ) + } + + ChoiceServer(navController, server) + + val isError = !email.value.contains("@") || + password.value.length < 8 || + confirmPassword.value != password.value + + LoadingButton( + loading = loading, + onClick = { submit(email.value, password.value, passwordHint.value) }, + enabled = !isError, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp) + ) { + Text(stringResource(R.string.Register)) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Unlock.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Unlock.kt new file mode 100644 index 00000000..63a3dde9 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/auth/Unlock.kt @@ -0,0 +1,201 @@ +package dev.medzik.librepass.android.ui.screens.auth + +import android.security.keystore.KeyPermanentlyInvalidatedException +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.compose.ui.textfield.PasswordAnimatedTextField +import dev.medzik.android.compose.ui.textfield.TextFieldValue +import dev.medzik.android.crypto.KeyStore +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.android.utils.showToast +import dev.medzik.libcrypto.Argon2 +import dev.medzik.libcrypto.Hex +import dev.medzik.libcrypto.X25519 +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.popUpToDestination +import dev.medzik.librepass.android.ui.screens.vault.Vault +import dev.medzik.librepass.android.utils.KeyAlias +import dev.medzik.librepass.android.utils.checkIfBiometricAvailable +import dev.medzik.librepass.android.utils.debugLog +import dev.medzik.librepass.android.utils.showBiometricPromptForUnlock +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.utils.Cryptography.computeAesKey +import dev.medzik.librepass.utils.Cryptography.computePasswordHash +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable + +@Serializable +object Unlock + +@Composable +fun UnlockScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + // context must be FragmentActivity to show biometric prompt + val context = LocalContext.current as FragmentActivity + + val scope = rememberCoroutineScope() + + var loading by rememberMutable(false) + val password = rememberMutable("") + + val credentials = viewModel.credentialRepository.get() ?: return + + fun onUnlock(password: String) { + // disable button + loading = true + + scope.launch(Dispatchers.IO) { + try { + loading = true + + // compute base password hash + val passwordHash = computePasswordHash( + password = password, + email = credentials.email, + argon2Function = Argon2( + 32, + credentials.parallelism, + credentials.memory, + credentials.iterations + ) + ) + + val publicKey = X25519.publicFromPrivate(passwordHash.hash) + + if (Hex.encode(publicKey) != credentials.publicKey) + throw Exception("Invalid password") + + viewModel.vault.aesKey = computeAesKey(passwordHash.hash) + + // run only if loading is true (if no error occurred) + if (loading) { + runOnUiThread { + navController.navigate(Vault) { + popUpToDestination(Vault) + } + } + } + } catch (e: Exception) { + // if password is invalid + loading = false + context.showToast(R.string.Error_InvalidCredentials) + } + } + } + + fun showBiometric() { + try { + showBiometricPromptForUnlock( + context, + KeyStore.initForDecryption( + alias = KeyAlias.BiometricAesKey, + initializationVector = Hex.decode(credentials.biometricAesKeyIV!!), + deviceAuthentication = false + ), + onAuthenticationSucceeded = { cipher -> + viewModel.vault.aesKey = KeyStore.decrypt(cipher, credentials.biometricAesKey!!) + + navController.navigate(Vault) { + popUpToDestination(Vault) + } + }, + onAuthenticationFailed = { } + ) + } catch (e: KeyPermanentlyInvalidatedException) { + // after adding or removing fingerprint, the key is invalidated + context.showToast(R.string.BiometricKeyInvalidated) + + try { + KeyStore.deleteKey(KeyAlias.BiometricAesKey) + runBlocking { + viewModel.credentialRepository.update( + credentials.copy( + biometricReSetup = true, + biometricAesKey = null, + biometricAesKeyIV = null + ) + ) + } + } catch (e: Exception) { + e.debugLog() + } + } catch (e: Exception) { + e.showErrorToast(context) + } + } + + LaunchedEffect(scope) { + if (credentials.biometricAesKey != null && checkIfBiometricAvailable(context)) + showBiometric() + } + + PasswordAnimatedTextField( + label = stringResource(R.string.Password), + value = TextFieldValue.fromMutableState(password) + ) + + LoadingButton( + loading = loading, + onClick = { onUnlock(password.value) }, + enabled = password.value.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .padding(horizontal = 80.dp) + ) { + Text(stringResource(R.string.Unlock)) + } + + if (credentials.biometricAesKey != null && checkIfBiometricAvailable(context)) { + Column( + modifier = Modifier.fillMaxWidth().fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Spacer(Modifier) + + ElevatedButton( + onClick = { showBiometric() }, + modifier = Modifier.padding(bottom = 42.dp), + contentPadding = PaddingValues(16.dp) + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + } + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Navigation.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Navigation.kt new file mode 100644 index 00000000..5bb48696 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Navigation.kt @@ -0,0 +1,62 @@ +package dev.medzik.librepass.android.ui.screens.settings + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.ui.DefaultScaffold +import dev.medzik.librepass.android.ui.TopBarWithBack +import dev.medzik.librepass.android.ui.screens.settings.account.* + +fun NavGraphBuilder.settingsNavigation(navController: NavController) { + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.Settings, navController) }, + horizontalPadding = false + ) { + SettingsScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.Settings_Security, navController) }, + horizontalPadding = false + ) { + SettingsSecurityScreen() + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.Settings_Account, navController) }, + horizontalPadding = false + ) { + SettingsAccountScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.ChangeEmail, navController) } + ) { + SettingsAccountChangeEmailScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.ChangePassword, navController) } + ) { + SettingsAccountChangePasswordScreen(navController) + } + } + + composable { + DefaultScaffold( + topBar = { TopBarWithBack(title = R.string.DeleteAccount, navController) } + ) { + SettingsAccountDeleteAccountScreen(navController) + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Settings.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Settings.kt new file mode 100644 index 00000000..5586e4d1 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/Settings.kt @@ -0,0 +1,35 @@ +package dev.medzik.librepass.android.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.ManageAccounts +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import dev.medzik.android.compose.ui.IconBox +import dev.medzik.android.compose.ui.preference.BasicPreference +import dev.medzik.librepass.android.R +import kotlinx.serialization.Serializable + +@Serializable +object Settings + +@Composable +fun SettingsScreen(navController: NavController) { + Column { + BasicPreference( + leading = { IconBox(Icons.Default.Fingerprint) }, + title = stringResource(R.string.Settings_Security), + subtitle = stringResource(R.string.Settings_Security_Subtitle), + onClick = { navController.navigate(SettingsSecurity) } + ) + + BasicPreference( + leading = { IconBox(Icons.Default.ManageAccounts) }, + title = stringResource(R.string.Settings_Account), + subtitle = stringResource(R.string.Settings_Account_Subtitle), + onClick = { navController.navigate(SettingsAccount) } + ) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt new file mode 100644 index 00000000..4c684460 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsAccount.kt @@ -0,0 +1,71 @@ +package dev.medzik.librepass.android.ui.screens.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.LockReset +import androidx.compose.material.icons.filled.NoAccounts +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import dev.medzik.android.compose.ui.IconBox +import dev.medzik.android.compose.ui.preference.BasicPreference +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.ui.screens.Welcome +import dev.medzik.librepass.android.ui.screens.settings.account.SettingsAccountChangeEmail +import dev.medzik.librepass.android.ui.screens.settings.account.SettingsAccountChangePassword +import dev.medzik.librepass.android.ui.screens.settings.account.SettingsAccountDeleteAccount +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable + +@Serializable +object SettingsAccount + +@Composable +fun SettingsAccountScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + BasicPreference( + title = stringResource(R.string.ChangeEmail), + leading = { IconBox(Icons.Default.Email) }, + onClick = { navController.navigate(SettingsAccountChangeEmail) } + ) + + BasicPreference( + title = stringResource(R.string.ChangePassword), + leading = { IconBox(Icons.Default.LockReset) }, + onClick = { navController.navigate(SettingsAccountChangePassword) } + ) + + BasicPreference( + title = stringResource(R.string.Logout), + leading = { IconBox(Icons.AutoMirrored.Filled.Logout) }, + onClick = { + runBlocking { + val credentials = viewModel.credentialRepository.get()!! + + viewModel.credentialRepository.drop() + viewModel.cipherRepository.drop(credentials.userId) + + navController.navigate( + Welcome + ) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = false + inclusive = true + } + } + } + } + ) + + BasicPreference( + title = stringResource(R.string.DeleteAccount), + leading = { IconBox(Icons.Default.NoAccounts) }, + onClick = { navController.navigate(SettingsAccountDeleteAccount) } + ) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt new file mode 100644 index 00000000..b8fa04bc --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt @@ -0,0 +1,152 @@ +package dev.medzik.librepass.android.ui.screens.settings + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.IconBox +import dev.medzik.android.compose.ui.dialog.PickerDialog +import dev.medzik.android.compose.ui.dialog.rememberDialogState +import dev.medzik.android.compose.ui.preference.PropertyPreference +import dev.medzik.android.compose.ui.preference.SwitcherPreference +import dev.medzik.android.crypto.KeyStore +import dev.medzik.android.utils.runOnIOThread +import dev.medzik.librepass.android.MainActivity +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.database.datastore.VaultTimeoutValue +import dev.medzik.librepass.android.database.datastore.readVaultTimeout +import dev.medzik.librepass.android.database.datastore.writeVaultTimeout +import dev.medzik.librepass.android.utils.KeyAlias +import dev.medzik.librepass.android.utils.checkIfBiometricAvailable +import dev.medzik.librepass.android.utils.showBiometricPromptForSetup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object SettingsSecurity + +@Composable +fun SettingsSecurityScreen(viewModel: LibrePassViewModel = hiltViewModel()) { + val context = LocalContext.current + + val credentials = viewModel.credentialRepository.get() ?: return + + val scope = rememberCoroutineScope() + var biometricEnabled by remember { mutableStateOf(credentials.biometricAesKey != null) } + val timerDialogState = rememberDialogState() + var vaultTimeout by rememberMutable(readVaultTimeout(context)) + + // Biometric checked event handler (enable/disable biometric authentication) + fun biometricHandler() { + if (biometricEnabled) { + biometricEnabled = false + + scope.launch(Dispatchers.IO) { + viewModel.credentialRepository.update( + credentials.copy( + biometricReSetup = false, + biometricAesKey = null, + biometricAesKeyIV = null + ) + ) + } + + return + } + + showBiometricPromptForSetup( + context as MainActivity, + cipher = KeyStore.initForEncryption( + KeyAlias.BiometricAesKey, + deviceAuthentication = false + ), + onAuthenticationSucceeded = { cipher -> + val encryptedData = KeyStore.encrypt( + cipher = cipher, + clearBytes = viewModel.vault.aesKey + ) + + biometricEnabled = true + + scope.launch { + viewModel.credentialRepository.update( + credentials.copy( + biometricAesKey = encryptedData.cipherText, + biometricAesKeyIV = encryptedData.initializationVector + ) + ) + } + }, + onAuthenticationFailed = {} + ) + } + + @Composable + fun getVaultTimeoutTranslation(value: VaultTimeoutValue): String { + return when (value) { + VaultTimeoutValue.INSTANT -> stringResource(R.string.Timeout_Instant) + + VaultTimeoutValue.ONE_MINUTE -> pluralStringResource(R.plurals.minutes, 1, 1) + + VaultTimeoutValue.FIVE_MINUTES -> pluralStringResource(R.plurals.minutes, 5, 5) + + VaultTimeoutValue.FIFTEEN_MINUTES -> pluralStringResource(R.plurals.minutes, 15, 15) + + VaultTimeoutValue.THIRTY_MINUTES -> pluralStringResource(R.plurals.minutes, 30, 30) + + VaultTimeoutValue.ONE_HOUR -> pluralStringResource(R.plurals.hours, 1, 1) + + VaultTimeoutValue.NEVER -> stringResource(R.string.Timeout_Never) + } + } + + if (checkIfBiometricAvailable(context)) { + SwitcherPreference( + title = stringResource(R.string.UnlockWithBiometrics), + leading = { IconBox(Icons.Default.Fingerprint) }, + checked = biometricEnabled, + onCheckedChange = { biometricHandler() } + ) + } + + PropertyPreference( + title = stringResource(R.string.VaultTimeout), + leading = { IconBox(Icons.Default.Timer) }, + currentValue = getVaultTimeoutTranslation(vaultTimeout.timeout), + onClick = { timerDialogState.show() }, + ) + + PickerDialog( + state = timerDialogState, + title = stringResource(R.string.VaultTimeout), + items = VaultTimeoutValue.entries, + onSelected = { + vaultTimeout = vaultTimeout.copy(timeout = it) + runOnIOThread { writeVaultTimeout(context, vaultTimeout) } + } + ) { + Text( + text = getVaultTimeoutTranslation(it), + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangeEmail.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangeEmail.kt new file mode 100644 index 00000000..d0dd583a --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangeEmail.kt @@ -0,0 +1,105 @@ +package dev.medzik.librepass.android.ui.screens.settings.account + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.haveNetworkConnection +import dev.medzik.librepass.android.ui.components.TextInputField +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.client.Server +import dev.medzik.librepass.client.api.UserClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object SettingsAccountChangeEmail + +@Composable +fun SettingsAccountChangeEmailScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + val credentials = viewModel.credentialRepository.get() ?: return + + var newEmail by rememberMutable("") + var password by rememberMutable("") + var loading by rememberMutable(false) + + val scope = rememberCoroutineScope() + + val userClient = UserClient( + email = credentials.email, + apiKey = credentials.apiKey, + apiUrl = credentials.apiUrl ?: Server.PRODUCTION + ) + + fun changeEmail(newEmail: String, password: String) { + if (!context.haveNetworkConnection()) { + context.showToast(R.string.Error_NoInternetConnection) + return + } + + loading = true + + // TODO: show error "invalid password" + + scope.launch(Dispatchers.IO) { + try { + userClient.changeEmail(newEmail, password) + + navigateToWelcomeAndLogout(viewModel, navController, credentials.userId) + } catch (e: Exception) { + e.showErrorToast(context) + + loading = false + } + } + } + + TextInputField( + label = stringResource(R.string.NewEmail), + value = newEmail, + onValueChange = { newEmail = it }, + isError = newEmail.isNotEmpty() && !newEmail.contains('@'), + errorMessage = stringResource(R.string.Error_InvalidEmail), + keyboardType = KeyboardType.Email + ) + + TextInputField( + label = stringResource(R.string.Password), + value = password, + onValueChange = { password = it }, + emptySupportingText = true, + hidden = true, + keyboardType = KeyboardType.Password, + ) + + LoadingButton( + loading = loading, + onClick = { changeEmail(newEmail, password) }, + enabled = newEmail.isNotEmpty() && newEmail.contains('@') && password.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.ChangeEmail)) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangePassword.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangePassword.kt new file mode 100644 index 00000000..896c3b02 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/ChangePassword.kt @@ -0,0 +1,123 @@ +package dev.medzik.librepass.android.ui.screens.settings.account + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.haveNetworkConnection +import dev.medzik.librepass.android.ui.components.TextInputField +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.client.Server +import dev.medzik.librepass.client.api.UserClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object SettingsAccountChangePassword + +@Composable +fun SettingsAccountChangePasswordScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + val credentials = viewModel.credentialRepository.get() ?: return + + var oldPassword by rememberMutable("") + var newPassword by rememberMutable("") + var newPasswordConfirm by rememberMutable("") + var newPasswordHint by rememberMutable("") + var loading by rememberMutable(false) + + val scope = rememberCoroutineScope() + + val userClient = UserClient( + email = credentials.email, + apiKey = credentials.apiKey, + apiUrl = credentials.apiUrl ?: Server.PRODUCTION + ) + + fun changePassword(oldPassword: String, newPassword: String, newPasswordHint: String) { + if (!context.haveNetworkConnection()) { + context.showToast(R.string.Error_NoInternetConnection) + return + } + + loading = true + + scope.launch(Dispatchers.IO) { + try { + userClient.changePassword(oldPassword, newPassword, newPasswordHint) + + navigateToWelcomeAndLogout(viewModel, navController, credentials.userId) + } catch (e: Exception) { + e.showErrorToast(context) + + loading = false + } + } + } + + TextInputField( + label = stringResource(R.string.OldPassword), + value = oldPassword, + onValueChange = { oldPassword = it }, + hidden = true, + emptySupportingText = true, + keyboardType = KeyboardType.Password + ) + + TextInputField( + label = stringResource(R.string.NewPassword), + value = newPassword, + onValueChange = { newPassword = it }, + hidden = true, + isError = newPassword.isNotEmpty() && newPassword.length < 8, + errorMessage = stringResource(R.string.Error_PasswordTooShort), + keyboardType = KeyboardType.Password + ) + + TextInputField( + label = stringResource(R.string.ConfirmNewPassword), + value = newPasswordConfirm, + onValueChange = { newPasswordConfirm = it }, + hidden = true, + isError = newPasswordConfirm.isNotEmpty() && newPasswordConfirm != newPassword, + errorMessage = stringResource(R.string.Error_PasswordsDoNotMatch), + keyboardType = KeyboardType.Password + ) + + TextInputField( + label = stringResource(R.string.PasswordHint), + value = newPasswordHint, + onValueChange = { newPasswordHint = it }, + emptySupportingText = true + ) + + LoadingButton( + loading = loading, + onClick = { changePassword(oldPassword, newPassword, newPasswordHint) }, + enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty() && newPasswordConfirm == newPassword, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.ChangePassword)) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/DeleteAccount.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/DeleteAccount.kt new file mode 100644 index 00000000..ee8af08a --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/DeleteAccount.kt @@ -0,0 +1,91 @@ +package dev.medzik.librepass.android.ui.screens.settings.account + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.haveNetworkConnection +import dev.medzik.librepass.android.ui.components.TextInputField +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.client.Server +import dev.medzik.librepass.client.api.UserClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object SettingsAccountDeleteAccount + +@Composable +fun SettingsAccountDeleteAccountScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + val credentials = viewModel.credentialRepository.get() ?: return + + var loading by rememberMutable(false) + var password by rememberMutable("") + val scope = rememberCoroutineScope() + + val userClient = UserClient( + email = credentials.email, + apiKey = credentials.apiKey, + apiUrl = credentials.apiUrl ?: Server.PRODUCTION + ) + + fun deleteAccount(password: String) { + if (!context.haveNetworkConnection()) { + context.showToast(R.string.Error_NoInternetConnection) + return + } + + loading = true + + scope.launch(Dispatchers.IO) { + try { + userClient.deleteAccount(password) + + navigateToWelcomeAndLogout(viewModel, navController, credentials.userId) + } catch (e: Exception) { + e.showErrorToast(context) + + loading = false + } + } + } + + TextInputField( + label = stringResource(R.string.Password), + value = password, + onValueChange = { password = it }, + hidden = true, + keyboardType = KeyboardType.Password + ) + + LoadingButton( + loading = loading, + onClick = { deleteAccount(password) }, + enabled = password.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.DeleteAccount)) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/Utils.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/Utils.kt new file mode 100644 index 00000000..1cf8da46 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/account/Utils.kt @@ -0,0 +1,26 @@ +package dev.medzik.librepass.android.ui.screens.settings.account + +import androidx.navigation.NavController +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.popUpToDestination +import dev.medzik.librepass.android.ui.screens.Welcome +import kotlinx.coroutines.runBlocking +import java.util.UUID + +fun navigateToWelcomeAndLogout( + viewModel: LibrePassViewModel, + navController: NavController, + userId: UUID +) { + runBlocking { + viewModel.credentialRepository.drop() + viewModel.cipherRepository.drop(userId) + } + + runOnUiThread { + navController.navigate(Welcome) { + popUpToDestination(Welcome) + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherAdd.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherAdd.kt new file mode 100644 index 00000000..f135ae84 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherAdd.kt @@ -0,0 +1,174 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +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.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.icons.TopAppBarBackIcon +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.parceler.CipherTypeParceler +import dev.medzik.librepass.android.ui.components.CipherEditFieldsCard +import dev.medzik.librepass.android.ui.components.CipherEditFieldsLogin +import dev.medzik.librepass.android.ui.components.CipherEditFieldsSecureNote +import dev.medzik.librepass.android.ui.components.TopBar +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.types.cipher.Cipher +import dev.medzik.librepass.types.cipher.CipherType +import dev.medzik.librepass.types.cipher.data.CipherCardData +import dev.medzik.librepass.types.cipher.data.CipherLoginData +import dev.medzik.librepass.types.cipher.data.CipherSecureNoteData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import kotlinx.serialization.Serializable +import java.util.UUID + +@Parcelize +@TypeParceler() +@Serializable +data class CipherAdd(val cipherType: CipherType): Parcelable + +@Composable +fun CipherAddScreen( + navController: NavController, + args: CipherAdd, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + + val scope = rememberCoroutineScope() + val credentials = remember { viewModel.credentialRepository.get() } ?: return + + var cipher by rememberMutable( + Cipher( + id = UUID.randomUUID(), + owner = credentials.userId, + type = args.cipherType, + loginData = if (args.cipherType == CipherType.Login) { + CipherLoginData(name = "") + } else null, + cardData = if (args.cipherType == CipherType.Card) { + CipherCardData(name = "", cardholderName = "", number = "") + } else null, + secureNoteData = if (args.cipherType == CipherType.SecureNote) { + CipherSecureNoteData(title = "", note = "") + } else null + ) + ) + + var loading by rememberMutable(false) + + fun submit() { + loading = true + + scope.launch(Dispatchers.IO) { + try { + viewModel.vault.save(cipher) + + runOnUiThread { navController.popBackStack() } + } catch (e: Exception) { + loading = false + e.showErrorToast(context) + } + } + } + + @Composable + fun buttonEnabled(): Boolean { + return when (cipher.type) { + CipherType.Login -> { + cipher.loginData!!.name.isNotEmpty() + } + + CipherType.SecureNote -> { + cipher.secureNoteData!!.title.isNotEmpty() && + cipher.secureNoteData!!.note.isNotEmpty() + } + + CipherType.Card -> { + cipher.cardData!!.name.isNotEmpty() && + cipher.cardData!!.cardholderName.isNotEmpty() && + cipher.cardData!!.number.isNotEmpty() + } + } + } + + Scaffold( + topBar = { + TopBar( + title = stringResource(R.string.AddNewCipher), + navigationIcon = { TopAppBarBackIcon(navController) } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + @Composable + fun button(): @Composable (cipher: Cipher) -> Unit { + return { + cipher = it + + LoadingButton( + loading = loading, + onClick = { submit() }, + enabled = buttonEnabled(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .padding(horizontal = 40.dp) + ) { + Text(stringResource(R.string.Add)) + } + } + } + + when (cipher.type) { + CipherType.Login -> { + CipherEditFieldsLogin( + navController, + cipher, + button() + ) + } + + CipherType.SecureNote -> { + CipherEditFieldsSecureNote( + cipher, + button() + ) + } + + CipherType.Card -> { + CipherEditFieldsCard( + cipher, + button() + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherEdit.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherEdit.kt new file mode 100644 index 00000000..2a3cab38 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherEdit.kt @@ -0,0 +1,177 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import androidx.compose.foundation.layout.Column +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.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.icons.TopAppBarBackIcon +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.LoadingButton +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.ui.components.CipherEditFieldsCard +import dev.medzik.librepass.android.ui.components.CipherEditFieldsLogin +import dev.medzik.librepass.android.ui.components.CipherEditFieldsSecureNote +import dev.medzik.librepass.android.ui.components.TopBar +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.types.cipher.Cipher +import dev.medzik.librepass.types.cipher.CipherType +import dev.medzik.librepass.types.cipher.data.PasswordHistory +import dev.medzik.otp.OTPParameters +import dev.medzik.otp.TOTPGenerator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import java.util.Date +import java.util.UUID + +@Serializable +data class CipherEdit(val cipherId: String) + +@Composable +fun CipherEditScreen( + navController: NavController, + args: CipherEdit, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + + val scope = rememberCoroutineScope() + + val oldCipher = remember { viewModel.vault.find(UUID.fromString(args.cipherId)) } ?: return + var cipher by rememberMutable(oldCipher) + + var loading by rememberMutable(false) + + fun submit() { + loading = true + + scope.launch(Dispatchers.IO) { + if (cipher.type == CipherType.Login) { + val basePassword = oldCipher.loginData!!.password + val newPassword = cipher.loginData!!.password + + if (basePassword != null && basePassword != newPassword) { + val newList = mutableListOf() + val oldList = oldCipher.loginData!!.passwordHistory + if (oldList != null) newList.addAll(oldList) + + newList.add(PasswordHistory(basePassword, Date())) + + cipher = cipher.copy(loginData = cipher.loginData!!.copy(passwordHistory = newList)) + } + } + + try { + viewModel.vault.save(cipher) + + runOnUiThread { navController.popBackStack() } + } catch (e: Exception) { + loading = false + e.showErrorToast(context) + } + } + } + + @Composable + fun buttonEnabled(): Boolean { + return when (cipher.type) { + CipherType.Login -> { + cipher.loginData!!.name.isNotEmpty() && + ( + cipher.loginData!!.twoFactor.isNullOrBlank() || + runCatching { + val params = OTPParameters.parseUrl(cipher.loginData?.twoFactor) + TOTPGenerator.now(params) + }.isSuccess + ) + } + + CipherType.SecureNote -> { + cipher.secureNoteData!!.title.isNotEmpty() && + cipher.secureNoteData!!.note.isNotEmpty() + } + + CipherType.Card -> { + cipher.cardData!!.name.isNotEmpty() && + cipher.cardData!!.cardholderName.isNotEmpty() && + cipher.cardData!!.number.isNotEmpty() + } + } + } + + Scaffold( + topBar = { + TopBar( + title = stringResource(R.string.AddNewCipher), + navigationIcon = { TopAppBarBackIcon(navController) } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + @Composable + fun button(): @Composable (cipher: Cipher) -> Unit { + return { + cipher = it + + LoadingButton( + loading = loading, + onClick = { submit() }, + enabled = buttonEnabled(), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .padding(horizontal = 40.dp) + ) { + Text(stringResource(R.string.Save)) + } + } + } + + when (cipher.type) { + CipherType.Login -> { + CipherEditFieldsLogin( + navController, + cipher, + button() + ) + } + + CipherType.SecureNote -> { + CipherEditFieldsSecureNote( + cipher, + button() + ) + } + + CipherType.Card -> { + CipherEditFieldsCard( + cipher, + button() + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt new file mode 100644 index 00000000..34d5d0af --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/CipherView.kt @@ -0,0 +1,511 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Web +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +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 androidx.navigation.NavController +import dev.medzik.android.compose.colorizePasswordTransformation +import dev.medzik.android.compose.icons.TopAppBarBackIcon +import dev.medzik.android.compose.icons.VisibilityIcon +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.GroupTitle +import dev.medzik.android.compose.ui.dialog.BaseDialog +import dev.medzik.android.compose.ui.dialog.rememberDialogState +import dev.medzik.android.compose.ui.textfield.AnimatedTextField +import dev.medzik.android.compose.ui.textfield.TextFieldValue +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.ui.components.TopBar +import dev.medzik.librepass.android.utils.SHORTEN_NAME_LENGTH +import dev.medzik.librepass.android.utils.shorten +import dev.medzik.librepass.types.cipher.CipherType +import dev.medzik.otp.OTPParameters +import dev.medzik.otp.TOTPGenerator +import kotlinx.coroutines.delay +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.UUID +import java.util.concurrent.TimeUnit + +@Serializable +data class CipherView(val cipherId: String) + +@Composable +fun CipherViewScreen( + navController: NavController, + args: CipherView, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val cipher = remember { viewModel.vault.find(UUID.fromString(args.cipherId)) } ?: return + + var totpCode by rememberMutable("") + var totpElapsed by rememberMutable(0) + var totpDigits by rememberMutable(6) + var totpPeriod by rememberMutable(0) + + LaunchedEffect(Unit) { + fun calculateElapsed(): Long { + val unixSeconds = System.currentTimeMillis() / 1000 + val counter = TimeUnit.SECONDS.toMillis(unixSeconds) / TimeUnit.SECONDS.toMillis(totpPeriod.toLong()) + val nextUnixSeconds = (counter + 1) * totpPeriod + return totpPeriod - (nextUnixSeconds - unixSeconds) + } + + if (cipher.type == CipherType.Login && !cipher.loginData?.twoFactor.isNullOrEmpty()) { + val params = OTPParameters.parseUrl(cipher.loginData?.twoFactor) + totpCode = TOTPGenerator.now(params) + + totpPeriod = params.period.value + totpDigits = params.digits.value + totpCode = TOTPGenerator.now(params) + + while (true) { + totpElapsed = calculateElapsed().toInt() + if (totpElapsed == 30) { + totpCode = TOTPGenerator.now(params) + } + + delay(TimeUnit.SECONDS.toMillis(1)) + } + } + } + + @Composable + fun CipherViewLogin() { + val cipherData = cipher.loginData!! + + CipherField( + title = stringResource(R.string.Name), + value = cipherData.name, + icon = Icons.Default.AccountCircle + ) + + if (!cipherData.email.isNullOrEmpty() || + !cipherData.username.isNullOrEmpty() || + !cipherData.password.isNullOrEmpty() + ) { + GroupTitle( + stringResource(R.string.LoginDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + CipherField( + title = stringResource(R.string.Email), + value = cipherData.email, + copy = true, + icon = Icons.Default.Email + ) + + CipherField( + title = stringResource(R.string.Username), + value = cipherData.username, + copy = true, + icon = Icons.Default.Badge + ) + + val passwordHistoryDialog = rememberDialogState() + + CipherField( + title = stringResource(R.string.Password), + value = cipherData.password, + copy = true, + hidden = true, + icon = Icons.Default.Password, + customIcon = { + if (cipherData.passwordHistory != null) { + IconButton(onClick = { passwordHistoryDialog.show() }) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null + ) + } + } + } + ) + + BaseDialog( + state = passwordHistoryDialog + ) { + val clipboardManager = LocalClipboardManager.current + val parser = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + + val passwords = (cipherData.passwordHistory ?: return@BaseDialog).asReversed() + + LazyColumn( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + for (i in passwords.indices) { + item { + Row( + modifier = Modifier.padding(vertical = 4.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = parser.format(passwords[i].lastUsed), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + + Text( + text = passwords[i].password, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + + IconButton(onClick = { + clipboardManager.setText( + AnnotatedString(passwords[i].password) + ) + }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null + ) + } + } + } + } + } + } + } + + if (!cipherData.twoFactor.isNullOrEmpty()) { + GroupTitle( + stringResource(R.string.TwoFactorAuthentication), + modifier = Modifier.padding(top = 8.dp) + ) + + CipherField( + title = null, + value = totpCode.chunked(totpDigits / 2).joinToString(" "), + copy = true, + copyValue = totpCode, + leading = { + Box( + contentAlignment = Alignment.Center + ) { + val progress by animateFloatAsState( + targetValue = 1 - (totpElapsed.toFloat() / totpPeriod.toFloat()), + animationSpec = tween(500), + label = "" + ) + + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.size(36.dp) + ) + + Text( + text = (totpPeriod - totpElapsed).toString(), + modifier = Modifier.padding(2.dp) + ) + } + } + ) + } + + if (!cipherData.uris.isNullOrEmpty()) { + GroupTitle( + stringResource(R.string.WebsiteDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + cipherData.uris?.forEachIndexed { index, it -> + CipherField( + title = stringResource(R.string.WebsiteAddress) + " ${index + 1}", + value = it, + openUri = true, + uri = it, + copy = true, + icon = Icons.Default.Web + ) + } + } + + if (!cipherData.notes.isNullOrEmpty()) { + GroupTitle( + stringResource(R.string.OtherDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + CipherField( + title = stringResource(R.string.Notes), + value = cipherData.notes, + copy = true, + icon = Icons.AutoMirrored.Filled.Notes + ) + } + } + + @Composable + fun CipherViewSecureNote() { + val cipherData = cipher.secureNoteData!! + + CipherField( + title = stringResource(R.string.Title), + value = cipherData.title, + copy = true + ) + + CipherField( + title = stringResource(R.string.Notes), + value = cipherData.note, + copy = true, + icon = Icons.AutoMirrored.Filled.Notes + ) + } + + @Composable + fun CipherViewCard() { + val cipherData = cipher.cardData!! + + CipherField( + title = stringResource(R.string.Name), + value = cipherData.name, + copy = true + ) + + GroupTitle( + stringResource(R.string.CardDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + CipherField( + title = stringResource(R.string.CardholderName), + value = cipherData.cardholderName, + copy = true + ) + + CipherField( + title = stringResource(R.string.CardNumber), + value = cipherData.number, + copy = true, + hidden = true + ) + + if (!cipherData.expMonth.isNullOrEmpty()) { + CipherField( + title = stringResource(R.string.ExpirationMonth), + value = cipherData.expMonth.toString(), + copy = true + ) + } + + if (!cipherData.expYear.isNullOrEmpty()) { + CipherField( + title = stringResource(R.string.ExpirationYear), + value = cipherData.expYear.toString(), + copy = true + ) + } + + if (!cipherData.code.isNullOrEmpty()) { + CipherField( + title = stringResource(R.string.SecurityCode), + value = cipherData.code, + copy = true, + hidden = true + ) + } + + if (!cipherData.notes.isNullOrEmpty()) { + GroupTitle( + stringResource(R.string.OtherDetails), + modifier = Modifier.padding(top = 8.dp) + ) + + CipherField( + title = stringResource(R.string.Notes), + value = cipherData.notes, + copy = true + ) + } + } + + Scaffold( + topBar = { + TopBar( + title = when (cipher.type) { + CipherType.Login -> cipher.loginData!!.name + CipherType.SecureNote -> cipher.secureNoteData!!.title + CipherType.Card -> cipher.cardData!!.cardholderName + }.shorten(SHORTEN_NAME_LENGTH), + navigationIcon = { TopAppBarBackIcon(navController) } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { + navController.navigate( + CipherEdit( + args.cipherId + ) + ) + }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null + ) + } + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + ) { + item { + when (cipher.type) { + CipherType.Login -> CipherViewLogin() + CipherType.SecureNote -> CipherViewSecureNote() + CipherType.Card -> CipherViewCard() + } + } + + // Prevent covering fields with floating action button + item { + Spacer( + modifier = Modifier.size(72.dp) + ) + } + } + } +} + +@Composable +fun CipherField( + title: String?, + value: String?, + hidden: Boolean = false, + openUri: Boolean = false, + uri: String? = null, + copy: Boolean = false, + copyValue: String? = value, + icon: ImageVector? = null, + leading: @Composable RowScope.() -> Unit = {}, + customIcon: (@Composable () -> Unit)? = null +) { + if (value.isNullOrEmpty()) return + + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + + var visibility by rememberMutable(false) + + AnimatedTextField( + modifier = Modifier.padding(vertical = 10.dp), + value = TextFieldValue( + value = value, + editable = false + ), + label = title, + visualTransformation = if (hidden) { + if (visibility) { + colorizePasswordTransformation() + } else { + PasswordVisualTransformation() + } + } else VisualTransformation.None, + leading = { + leading() + + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null + ) + } + }, + trailing = { + if (customIcon != null) { + customIcon() + } + + if (hidden) { + IconButton(onClick = { visibility = !visibility }) { + VisibilityIcon(visibility = visibility) + } + } + + if (openUri) { + IconButton( + onClick = { + try { + var address = uri!! + if (!address.contains("http(s)?://".toRegex())) + address = "https://$uri" + + uriHandler.openUri(address) + } catch (e: Exception) { + context.showToast("No application found for URI: $uri") + } + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null + ) + } + } + + if (copy) { + IconButton(onClick = { clipboardManager.setText(AnnotatedString(copyValue!!)) }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null + ) + } + } + } + ) +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Navigation.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Navigation.kt new file mode 100644 index 00000000..02526464 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Navigation.kt @@ -0,0 +1,51 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import dev.medzik.librepass.android.common.navtype.CipherTypeType +import dev.medzik.librepass.android.ui.DefaultScaffold +import dev.medzik.librepass.types.cipher.CipherType +import kotlin.reflect.typeOf + +fun NavGraphBuilder.vaultNavigation(navController: NavController) { + composable { + DefaultScaffold( + topBar = { VaultScreenTopBar(navController) }, + floatingActionButton = { VaultScreenFloatingActionButton(navController) } + ) { + VaultScreen(navController) + } + } + + composable { + val args = it.toRoute() + + CipherViewScreen(navController, args) + } + + composable( + typeMap = mapOf(typeOf() to CipherTypeType) + ) { + val args = it.toRoute() + + CipherAddScreen(navController, args) + } + + composable { + val args = it.toRoute() + + CipherEditScreen(navController, args) + } + + composable { + val args = it.toRoute() + + OtpConfigureScreen(navController, args) + } + + composable { + SearchScreen(navController) + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/OtpConfigure.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/OtpConfigure.kt new file mode 100644 index 00000000..da8b0be2 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/OtpConfigure.kt @@ -0,0 +1,265 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import android.Manifest +import android.util.Log +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.Permission +import dev.medzik.android.compose.icons.TopAppBarBackIcon +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.ComboBoxDropdown +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.ui.components.QrCodeScanner +import dev.medzik.librepass.android.ui.components.TextInputFieldBase +import dev.medzik.librepass.android.ui.components.TopBar +import dev.medzik.otp.OTPParameters +import dev.medzik.otp.OTPType +import dev.medzik.otp.TOTPGenerator +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class OtpConfigure(val cipherId: String) + +@Composable +fun OtpConfigureScreen( + navController: NavController, + args: OtpConfigure, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val cipher = remember { viewModel.vault.find(UUID.fromString(args.cipherId)) } ?: return + + Scaffold( + topBar = { + TopBar( + title = stringResource(R.string.ConfigureTwoFactor), + navigationIcon = { TopAppBarBackIcon(navController) } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + var qrScanning by rememberMutable(true) + + if (qrScanning) { + Text( + text = stringResource(R.string.ScanQrCode), + fontSize = 24.sp, + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + textAlign = TextAlign.Center + ) + + Permission( + permission = Manifest.permission.CAMERA, + onGranted = { + var wasScanned by rememberMutable(false) + var isTotpValid by rememberMutable(false) + var totpUri by rememberMutable("") + + QrCodeScanner { + if (!wasScanned && !isTotpValid) { + wasScanned = true + + isTotpValid = + runCatching { + TOTPGenerator.fromUrl(it) + }.isSuccess + + Log.i("TOTP_QR", "Valid: $isTotpValid") + + totpUri = it + + if (isTotpValid) { + navController.previousBackStackEntry!!.savedStateHandle["otpUri"] = totpUri + navController.popBackStack() + } + } + } + + if (wasScanned) { + if (!isTotpValid) { + Text( + text = stringResource(R.string.Error_InvalidURI), + color = MaterialTheme.colorScheme.error, + fontSize = 18.sp, + modifier = Modifier.padding(6.dp) + ) + } + } + }, + onDenied = { request -> + request() + + Text( + text = "No permissions to use camera.", + fontSize = 18.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + textAlign = TextAlign.Center + ) + } + ) + + Button( + onClick = { + qrScanning = false + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.EnterKeyManually)) + } + } else { + var beginParams: OTPParameters? = null + if (cipher.loginData?.twoFactor != null) { + beginParams = OTPParameters.parseUrl(cipher.loginData?.twoFactor) + } + + var totpSecret by rememberMutable(beginParams?.secret?.encoded?.toString() ?: "") + + var digits by rememberMutable(beginParams?.digits?.value?.toString() ?: "6") + var type by rememberMutable(beginParams?.type ?: OTPType.TOTP) + var algorithm by rememberMutable(beginParams?.algorithm ?: OTPParameters.Algorithm.SHA1) + + var period by rememberMutable(beginParams?.period?.value?.toString() ?: "30") + var counter by rememberMutable(beginParams?.counter?.value?.toString() ?: "0") + + TextInputFieldBase( + label = stringResource(R.string.TwoFactorSecret), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + value = totpSecret, + onValueChange = { totpSecret = it } + ) + + ComboBoxDropdown( + values = OTPType.entries.toTypedArray(), + value = type, + modifier = Modifier.fillMaxWidth(), + label = { + Text(stringResource(R.string.Type)) + }, + onValueChange = { type = it } + ) + + ComboBoxDropdown( + values = OTPParameters.Algorithm.entries.toTypedArray(), + value = algorithm, + modifier = Modifier.fillMaxWidth(), + label = { + Text(stringResource(R.string.Algorithm)) + }, + onValueChange = { algorithm = it } + ) + + TextInputFieldBase( + label = stringResource(R.string.Digits), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + keyboardType = KeyboardType.Number, + value = digits, + onValueChange = { digits = it } + ) + + TextInputFieldBase( + label = if (type == OTPType.TOTP) stringResource(R.string.Period) else stringResource(R.string.Counter), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + keyboardType = KeyboardType.Number, + value = (if (type == OTPType.TOTP) period else counter).toString(), + onValueChange = { + if (type == OTPType.TOTP) + period = it + else + counter = it + } + ) + + var otpUri by rememberMutable("") + val otpCodeError = runCatching { + otpUri = generateOtpUri(totpSecret, type, digits.toInt(), period.toInt(), counter.toLong(), algorithm) + }.isSuccess + + Button( + onClick = { qrScanning = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.ScanQrCode)) + } + + Button( + onClick = { + navController.previousBackStackEntry!!.savedStateHandle["otpUri"] = otpUri + navController.popBackStack() + }, + enabled = totpSecret.isNotEmpty() && otpCodeError, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp) + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.Save)) + } + } + } + } +} + +private fun generateOtpUri( + secret: String, + type: OTPType, + digits: Int, + period: Int, + counter: Long, + algorithm: OTPParameters.Algorithm +): String { + val otpBuilder = OTPParameters.builder() + .type(type) + .digits(OTPParameters.Digits.valueOf(digits)) + .secret(OTPParameters.Secret(secret)) + .algorithm(algorithm) + + when (type) { + OTPType.TOTP -> otpBuilder.period(OTPParameters.Period.valueOf(period)) + OTPType.HOTP -> otpBuilder.counter(OTPParameters.Counter(counter)) + } + + return otpBuilder.build().encodeToUrl() +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Search.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Search.kt new file mode 100644 index 00000000..98f8acca --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Search.kt @@ -0,0 +1,115 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.icons.TopAppBarBackIcon +import dev.medzik.android.compose.rememberMutable +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.ui.components.CipherCard +import dev.medzik.librepass.types.cipher.CipherType +import kotlinx.serialization.Serializable + +@Serializable +object Search + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val ciphers = remember { viewModel.vault.getSortedCiphers() } + + var searchText by rememberMutable("") + + Scaffold( + topBar = { + TopAppBar( + title = { + TextField( + value = searchText, + onValueChange = { searchText = it }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + ) + }, + navigationIcon = { TopAppBarBackIcon(navController) } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + ) { + val filteredCiphers = ciphers.filter { + when (it.type) { + CipherType.Login -> { + it.loginData!!.name.lowercase().contains(searchText) || + it.loginData!!.username?.lowercase()?.contains(searchText) ?: false + } + + CipherType.SecureNote -> { + it.secureNoteData!!.title.lowercase().contains(searchText) + } + + CipherType.Card -> { + it.cardData!!.cardholderName.lowercase().contains(searchText) + } + } + } + + for (cipher in filteredCiphers) { + item { + CipherCard( + cipher = cipher, + showCipherActions = false, + onClick = { + navController.navigate( + CipherView( + cipher.id.toString() + ) + ) + }, + onEdit = {}, + onDelete = {} + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Vault.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Vault.kt new file mode 100644 index 00000000..e3eaa1b8 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/vault/Vault.kt @@ -0,0 +1,308 @@ +package dev.medzik.librepass.android.ui.screens.vault + +import android.app.Activity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import dev.medzik.android.compose.rememberMutable +import dev.medzik.android.compose.ui.dialog.rememberDialogState +import dev.medzik.android.crypto.KeyStore +import dev.medzik.librepass.android.MainActivity +import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.business.syncCiphers +import dev.medzik.librepass.android.common.LibrePassViewModel +import dev.medzik.librepass.android.common.haveNetworkConnection +import dev.medzik.librepass.android.ui.components.CipherCard +import dev.medzik.librepass.android.ui.components.CipherTypeDialog +import dev.medzik.librepass.android.ui.components.TopBar +import dev.medzik.librepass.android.ui.screens.settings.Settings +import dev.medzik.librepass.android.utils.KeyAlias +import dev.medzik.librepass.android.utils.checkIfBiometricAvailable +import dev.medzik.librepass.android.utils.showBiometricPromptForSetup +import dev.medzik.librepass.android.utils.showErrorToast +import dev.medzik.librepass.client.Server +import dev.medzik.librepass.client.api.CipherClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +object Vault + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultScreen( + navController: NavController, + viewModel: LibrePassViewModel = hiltViewModel() +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val pullToRefreshState = rememberPullToRefreshState() + + var ciphers by remember { mutableStateOf(viewModel.vault.getSortedCiphers()) } + val credentials = remember { viewModel.credentialRepository.get() } ?: return + + val cipherClient = CipherClient( + apiKey = credentials.apiKey, + apiUrl = credentials.apiUrl ?: Server.PRODUCTION + ) + + fun reSetupBiometrics() { + // enable biometric authentication if possible + if (checkIfBiometricAvailable(context)) { + showBiometricPromptForSetup( + context as MainActivity, + cipher = KeyStore.initForEncryption( + KeyAlias.BiometricAesKey, + deviceAuthentication = false + ), + onAuthenticationSucceeded = { cipher -> + val encryptedData = KeyStore.encrypt(cipher, viewModel.vault.aesKey) + + scope.launch(Dispatchers.IO) { + viewModel.credentialRepository.update( + credentials.copy( + biometricReSetup = false, + biometricAesKey = encryptedData.cipherText, + biometricAesKeyIV = encryptedData.initializationVector + ) + ) + } + }, + onAuthenticationFailed = { + scope.launch(Dispatchers.IO) { + viewModel.credentialRepository.update( + credentials.copy( + biometricReSetup = false + ) + ) + } + } + ) + } else { + scope.launch(Dispatchers.IO) { + viewModel.credentialRepository.update( + credentials.copy( + biometricReSetup = false + ) + ) + } + } + } + + LaunchedEffect(scope) { + pullToRefreshState.startRefresh() + + if (credentials.biometricReSetup) { + try { + reSetupBiometrics() + } catch (e: Exception) { + e.showErrorToast(context) + } + } + + // get local stored ciphers + val dbCiphers = viewModel.cipherRepository.getAll(credentials.userId) + + // decrypt database if needed + if (viewModel.vault.ciphers.isEmpty()) { + viewModel.vault.decrypt(dbCiphers) + } + + // sort ciphers and update UI + ciphers = viewModel.vault.getSortedCiphers() + + // sync remote ciphers + if (context.haveNetworkConnection()) { + scope.launch(Dispatchers.IO) { + try { + syncCiphers( + context, + credentials, + client = cipherClient, + vault = viewModel.vault + ) + + // sort ciphers and update UI + ciphers = viewModel.vault.getSortedCiphers() + } catch (e: Exception) { + e.showErrorToast(context) + } finally { + pullToRefreshState.endRefresh() + } + } + } else { + pullToRefreshState.endRefresh() + } + } + + if (pullToRefreshState.isRefreshing) { + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + try { + syncCiphers( + context, + credentials, + client = cipherClient, + vault = viewModel.vault + ) + } catch (e: Exception) { + e.showErrorToast(context) + } finally { + pullToRefreshState.endRefresh() + } + } + } + } + + Box { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(connection = pullToRefreshState.nestedScrollConnection) + ) { + items(ciphers.size) { index -> + CipherCard( + cipher = ciphers[index], + onClick = { cipher -> + navController.navigate( + CipherView( + cipher.id.toString() + ) + ) + }, + onEdit = { cipher -> + navController.navigate( + CipherEdit( + cipher.id.toString() + ) + ) + }, + onDelete = { cipher -> + scope.launch(Dispatchers.IO) { + try { + cipherClient.delete(cipher.id) + viewModel.cipherRepository.delete(cipher.id) + + ciphers = ciphers.filter { it.id != cipher.id } + } catch (e: Exception) { + e.showErrorToast(context) + } + } + } + ) + } + + // Prevent covering ciphers with floating action button + item { + Spacer( + modifier = Modifier.size(72.dp) + ) + } + } + + PullToRefreshContainer( + modifier = Modifier.align(alignment = Alignment.TopCenter), + state = pullToRefreshState, + ) + } +} + +@Composable +fun VaultScreenTopBar(navController: NavController) { + TopBar( + title = stringResource(R.string.Vault), + actions = { + val context = LocalContext.current + var expanded by rememberMutable(false) + IconButton(onClick = { navController.navigate(Search) }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + } + + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.Settings)) }, + onClick = { + expanded = false + navController.navigate(Settings) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.LockVault)) }, + onClick = { + (context as MainActivity).vault.deleteSecrets(context) + + // close application + (context as Activity).finish() + } + ) + } + } + ) +} + +@Composable +fun VaultScreenFloatingActionButton(navController: NavController) { + val dialogState = rememberDialogState() + + FloatingActionButton( + onClick = { dialogState.show() } + ) { + Icon(Icons.Default.Add, contentDescription = null) + } + + CipherTypeDialog( + dialogState, + onSelected = { cipherType -> + navController.navigate( + CipherAdd( + cipherType + ) + ) + } + ) +} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Color.kt b/app/src/main/java/dev/medzik/librepass/android/ui/theme/Color.kt similarity index 100% rename from ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Color.kt rename to app/src/main/java/dev/medzik/librepass/android/ui/theme/Color.kt diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt b/app/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt similarity index 93% rename from ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt rename to app/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt index e5b8eb6f..6690f402 100644 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt +++ b/app/src/main/java/dev/medzik/librepass/android/ui/theme/Theme.kt @@ -1,7 +1,6 @@ package dev.medzik.librepass.android.ui.theme import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -79,12 +78,14 @@ private val darkColorScheme = darkColorScheme( ) @Composable -fun LibrePassTheme(content: @Composable () -> Unit) { - val darkTheme = isSystemInDarkTheme() - +fun LibrePassTheme( + darkTheme: Boolean, + // Dynamic color is available on Android 12+ + dynamicColor: Boolean, + content: @Composable () -> Unit +) { val colorScheme = when { - // Dynamic color is available on Android 12+ - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Type.kt b/app/src/main/java/dev/medzik/librepass/android/ui/theme/Type.kt similarity index 100% rename from ui-logic/src/main/java/dev/medzik/librepass/android/ui/theme/Type.kt rename to app/src/main/java/dev/medzik/librepass/android/ui/theme/Type.kt diff --git a/app/src/main/java/dev/medzik/librepass/android/utils/Biometric.kt b/app/src/main/java/dev/medzik/librepass/android/utils/Biometric.kt new file mode 100644 index 00000000..d78136e8 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/utils/Biometric.kt @@ -0,0 +1,75 @@ +package dev.medzik.librepass.android.utils + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import dev.medzik.librepass.android.R +import javax.crypto.Cipher + +fun showBiometricPromptForUnlock( + context: FragmentActivity, + cipher: Cipher, + onAuthenticationSucceeded: (Cipher) -> Unit, + onAuthenticationFailed: () -> Unit +) { + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.BiometricUnlock_Title)) + .setSubtitle(context.getString(R.string.BiometricUnlock_Subtitle)) + .setNegativeButtonText(context.getString(R.string.BiometricUnlock_Button_UsePassword)) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + showBiometricPrompt(context, promptInfo, cipher, onAuthenticationSucceeded, onAuthenticationFailed) +} + +fun showBiometricPromptForSetup( + context: FragmentActivity, + cipher: Cipher, + onAuthenticationSucceeded: (Cipher) -> Unit, + onAuthenticationFailed: () -> Unit +) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.BiometricSetup_Title)) + .setSubtitle(context.getString(R.string.BiometricSetup_Subtitle)) + .setNegativeButtonText(context.getString(R.string.BiometricSetup_Button_Cancel)) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + showBiometricPrompt(context, promptInfo, cipher, onAuthenticationSucceeded, onAuthenticationFailed) +} + +private fun showBiometricPrompt( + context: FragmentActivity, + promptInfo: BiometricPrompt.PromptInfo, + cipher: Cipher, + onAuthenticationSucceeded: (Cipher) -> Unit, + onAuthenticationFailed: () -> Unit +) { + val biometricPrompt = BiometricPrompt( + context, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) = onAuthenticationFailed() + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) = onAuthenticationSucceeded(result.cryptoObject?.cipher!!) + + override fun onAuthenticationFailed() = onAuthenticationFailed() + } + ) + + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) +} + +fun checkIfBiometricAvailable(context: Context): Boolean { + val status = BiometricManager.from(context) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + + // return true when available + return status == BiometricManager.BIOMETRIC_SUCCESS +} diff --git a/app/src/main/java/dev/medzik/librepass/android/utils/Exception.kt b/app/src/main/java/dev/medzik/librepass/android/utils/Exception.kt new file mode 100644 index 00000000..7aa90c3a --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/utils/Exception.kt @@ -0,0 +1,64 @@ +package dev.medzik.librepass.android.utils + +import android.content.Context +import dev.medzik.android.utils.runOnUiThread +import dev.medzik.android.utils.showToast +import dev.medzik.librepass.android.BuildConfig +import dev.medzik.librepass.android.R +import dev.medzik.librepass.client.errors.ApiException +import dev.medzik.librepass.client.errors.ClientException +import dev.medzik.librepass.errors.ServerError +import kotlinx.coroutines.CancellationException + +/** Log exception if debugging is enabled. */ +fun Exception.debugLog() { + if (BuildConfig.DEBUG) { + printStackTrace() + } +} + +/** Handle exceptions. Show toast with an error message. */ +fun Exception.showErrorToast(context: Context) { + // ignore when job was cancelled (e.g. when left composable) + if (this is CancellationException) return + + // log exception trace if debugging is enabled + debugLog() + + val message = when (this) { +// // handle encrypt exception +// is EncryptException -> { context.getString(R.string.Error_EncryptionError) } + // ignore network error + is ClientException -> { + return + } + // handle api exceptions + is ApiException -> { + getTranslatedErrorMessage(context) + } + // handle other exceptions + else -> { + context.getString(R.string.Error_UnknownError) + } + } + + runOnUiThread { context.showToast(message) } +} + +fun ApiException.getTranslatedErrorMessage(context: Context): String { + return when (getServerError()) { +// ServerError.CipherNotFound -> context.getString(R.string.CipherNotFound) +// ServerError.CollectionNotFound -> context.getString(R.string.CollectionNotFound) + ServerError.Database -> context.getString(R.string.Database) + ServerError.Duplicated -> context.getString(R.string.Duplicated) + ServerError.EmailNotVerified -> context.getString(R.string.EmailNotVerified) + ServerError.InvalidBody -> context.getString(R.string.InvalidBody) + ServerError.InvalidSharedSecret -> context.getString(R.string.InvalidCredentials) + ServerError.InvalidToken -> context.getString(R.string.InvalidToken) +// ServerError.InvalidTwoFactor -> context.getString(R.string.InvalidTwoFactor) +// ServerError.NotFound -> context.getString(R.string.NotFound) + ServerError.RateLimit -> context.getString(R.string.RateLimit) + + else -> message + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/utils/KeyAlias.kt b/app/src/main/java/dev/medzik/librepass/android/utils/KeyAlias.kt new file mode 100644 index 00000000..32778753 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/utils/KeyAlias.kt @@ -0,0 +1,7 @@ +package dev.medzik.librepass.android.utils + +import dev.medzik.android.crypto.KeyStoreAlias + +enum class KeyAlias : KeyStoreAlias { + BiometricAesKey +} diff --git a/app/src/main/java/dev/medzik/librepass/android/utils/ShortenName.kt b/app/src/main/java/dev/medzik/librepass/android/utils/ShortenName.kt new file mode 100644 index 00000000..5f6728a6 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/utils/ShortenName.kt @@ -0,0 +1,12 @@ +package dev.medzik.librepass.android.utils + +const val SHORTEN_NAME_LENGTH = 16 +const val SHORTEN_USERNAME_LENGTH = 20 + +/** + * Returns a string shortened to the specified length. + * + * @param length Length of characters to which it will be shortened. + * @return The shortened string. + */ +fun String.shorten(length: Int) = if (this.length > length) take(length) + "..." else this diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000..0c2b10e6 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,101 @@ + + + + + %d ساعة + + + + %d ساعة + + + + %d دقيقة + + + + %d دقيقة + + قم بتشغيل المصادقة البيومترية + يلغي + إضافة حقل + استخدم كلمة المرور + أضف الخادم + يثبت + إضافة شفرة جديدة + إضافة + حذف الحساب + خطأ في التشفير/فك التشفير + رقم البطاقة + بيانات تسجيل الدخول + تأكيد كلمة المرور + تغيير كلمة المرور + يحرر + إسم صاحب البطاقة + ملاحظة آمنة + بيانات البطاقة + الغاء القفل + يرجى المصادقة على نفسك + بريد إلكتروني + تأكيد كلمة المرور الجديدة + تحذف + ‐بيانات الاعتماد غير صالحة + عنوان بريد إلكتروني غير رسمي + سنة انتهاء الخدمة + فاتح + كلمة المرور القديمة + مولدات كلمة المرور + شهر انتهاء الخدمة + إعدادات + الحساب + عنوان الخادم + خزنة القفل + كلمة السر قصيرة جداً + مرحبًا بك في LibrePass + السجل + أدخل عنوان بريدك الالكتروني + يُقدِّم + أبداً + أسود + اسم المستخدم + خطأ غير معروف + تفاصيل تسجيل الدخول + الغاء القفل + الأمن + النظام الافتراضي + الرسمي + زمن توقف خزنة + نوع الشفرة المختارة + فوري + يرجى التحقق من عنوان البريد الإلكتروني الخاص بك + ’تسجيل الدخول + الأرقام + عنوان + داكن + كلمات المرور غير متطابقة + تلميحات كلمة المرور + الحروف الرأسمالية + اختر الخادم + عنوان موقع ويب + مظهر + الغاء القفل مع المقاييس الحيوية + اسم + سمة + رمز الأمن + إضافة استضافة ذاتية + كلمة المرور + خزنة + منظر + تفاصيل أخرى + اختياري + موقع إلكتروني + ملحوظات + طول + حفظ + تسجيل خروج + كلمة المرور الجديدة + تم إرسال تلميح كلمة المرور إلى عنوان البريد الإلكتروني + الحصول على تلميحات كلمة المرور + كلمة المرور غير صحيحة + الرمز + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 00000000..5f590a73 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,119 @@ + + + Ablaufjahr + Konto löschen + Hell + Altes Passwort + Ablaufmonat + Einstellungen + Konto + Server-Adresse + Tresor sperren + Passwort ist zu kurz + Willkommen bei LibrePass + Registrieren + Geben Sie Ihre E-Mail Adresse ein + Senden + Nie + Schwarz + Benutzername + Unbekannter Fehler + Entsperren + Sicherheit + Ungültige E-Mail + Systemstandard + Bearbeiten + Offiziell + Tresor-Zeitlimit + Auswahl des Chiffrentyps + Sofort + Bitte bestätigen Sie Ihre E-Mail-Adresse + Anmeldung + Symbole + Zahlen + Titel + Dunkel + Passwörter stimmen nicht überein + Passwort-Hinweis + Großbuchstaben + Server auswählen + Erscheinungsbild + Entsperren mit Biometrie + Name + Thema + Sicherheitscode + Selbst gehosteten Server hinzufügen + Passwort + Tresor + Anzeigen + Optional + Notizen + Länge + Speichern + Abmelden + Löschen + Neues Passwort + Ein Passworthinweis wurde an die E-Mail-Adresse gesendet + Passwort-Hinweis abrufen + Ungültige Anmeldeinformationen + Passwort ist inkorrekt + + %d Stunde + %d Stunden + + Passwort-Generator + Kontaktdaten + Website-Adresse + + %d Minute + %d Minuten + + E-Mail + Sonstige Angaben + Website + Biometrische Authentifizierung aktivieren + Verschlüsselungs-/Entschlüsselungsfehler + Kartennummer + Abbrechen + Login-Daten + Passwort bestätigen + Passwort ändern + Feld hinzufügen + Karteninhabername + Hinzufügen + Sicherer Hinweis + Passwort verwenden + Kartendaten + Entsperren + Server hinzufügen + Bitte authentifizieren Sie sich + Neues Passwort bestätigen + Einrichtung + Neue Cipher hinzufügen + URI Adresse ist ungültig + Kartendetails + Die biometrische Autorisierung wurde von Android ungültig gemacht + Neue E-Mail-Adresse + E-Mail-Adresse ändern + Geheimnis manuell eingeben + Algorithmus + Ziffern + Zeitraum + Zähler + QR-Code scannen + Art + Konfigurieren Sie die Zwei-Faktor-Funktion + Zweiten Faktor löschen + Zwei-Faktor-Authentifizierung + Zwei-Faktor Geheimnis + Zu viele Anfragen + Interner Serverfehler - E-Mail kann nicht gesendet werden + Dupliziert + Unbekannter Server-Datenbankfehler + Cipher nicht gefunden + Ordner nicht gefunden + Bitte verifizieren Sie Ihre E-Mail-Adresse + Ungültige API-Anfrage + Ungültige Zugangsdaten + Ungültiges Login-Token + \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml new file mode 100644 index 00000000..28ef7cdc --- /dev/null +++ b/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,121 @@ + + + जोड़ें + कृपया स्वयं को प्रमाणित करें + अनलॉक + कार्ड नंबर + कार्डधारक का नाम + पासवर्ड बदलें + कार्ड डेटा + लॉगिन डेटा + सुरक्षित नोट + नए पासवर्ड की पुष्टि करें + पासवर्ड पुष्टि करें + मिटाएं + खाता मिटाएं + संपादित करें + ई-मेल + एन्क्रिप्शन/डिक्रिप्शन त्रुटि + अमान्य क्रेडेंशियल + अमान्य ई-मेल पता + पासवर्ड गलत है + पासवर्ड बहुत छोटा है + पासवर्ड मेल नहीं खाते + अज्ञात त्रुटि + समाप्ति महीना + समाप्ति वर्ष + पासवर्ड संकेत प्राप्त करें + लॉग इन + लॉगइन विवरण + लॉग आउट + नाम + नया पासवर्ड + अन्य विवरण + पासवर्ड जनरेटर + बड़े अक्षर + लंबाई + संख्याएं + पासवर्ड संकेत + पंजीकृत करें + सहेजें + साइफर प्रकार चुनें + सर्वर पता + स्वयं-होस्टेड जोड़ें + आधिकारिक + सेटिंग्स + दिखावट + सुरक्षा + जमा करें + थीम + काली + गहरी + हल्की + सिस्टम डिफॉल्ट + शीर्षक + अपना ई-मेल पता दर्ज करें + ईमेल पते पर पासवर्ड संकेत भेजा गया था + खोलें + बायोमेट्रिक्स से खोलें + सर्वर चुनें + + %d मिनट + %d मिनट + + + %d घंटा + %d घंटे + + नया cipher जोड़ें + बायोमेट्रिक प्रमाणीकरण चालू करें + पासवर्ड का प्रयोग करें + टिप्पणियां + कृपया अपना ई-मेल पता सत्यापित करें + सर्वर जोड़े + सेटअप + पुराना पासवर्ड + क्षेत्र जोड़ें + रद्द करें + पासवर्ड + उपयोक्तानाम + प्रतीक + देखें + सुरक्षा कोड + कभी नहीं + खाता + वेबसाइट + वेबसाइट पता + तुरंत + वैकल्पिक + कार्ड विवरण + नया ई-मेल पता + ई-मेल पता बदलें + बायोमेट्रिक कुंजी को एंड्रॉइड द्वारा अमान्य कर दिया गया है + URI पता अमान्य है + वॉल्ट बंद करें + लिब्रेपास में आपका स्वागत है + वॉल्ट + वॉल्ट टाइमआउट + द्वि-कारक प्रमाणीकरण + द्वि-कारक रहस्य + साइफर नहीं मिला + संग्रह नहीं मिला + अज्ञात सर्वर डाटाबेस त्रुटि + दोहराया गया + कृपया अपने ई-मेल पते की पुष्टि करें + अमान्य API अनुरोध + अवैध प्रत्यय पत्र + अमान्य लॉगिन टोकन + बहुत सारे अनुरोध + आंतरिक सर्वर त्रुटि - ईमेल भेजने में विफल + स्कैन QR कोड + मैन्युअल रूप से कुंजी दर्ज करें + द्वि-कारक हटाएं + प्रकार + एल्गोरिथ्म + अंक + अवधि + काउंटर + द्वि-कारक कॉन्फ़िगर करें + कोई इंटरनेट कनेक्शन नहीं + सर्वर से कोई कनेक्शन नहीं + \ No newline at end of file diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 00000000..8ca423eb --- /dev/null +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,93 @@ + + + + %d time + %d timer + + + %d minutt + %d minutter + + Legg til + Legg til felt + Legg til nytt chiffer + Legg til tjener + Avbryt + Skru på biometrisk identitetsbekreftelse + Oppsett + Bruk passord + Vennligst autentiser deg selv + Lås opp + Kortnummer + Kortinnehavers navn + Endre passord + Kort + Innloggingsdata + Sikkert notat + Bekreft nytt passord + Bekreft passord + Slett + Slett konto + Rediger + E-post + Kryptering/dekrypteringsfeil + Ugyldige identitetsdetaljer + Ugyldig e-postadresse + Passordet stemmer ikke + Passordet er for kort + Passordfeltene samsvarer ikke + Ukjent feil + Utløpsmåned + Utløpsår + Vis passordhint + Lås hvelv + Logg inn + Innloggingsdetaljer + Logg ut + Navn + Nytt passord + Notater + Gammelt passord + Annet + Passord + Passord generator + Store bokstaver + Lengde + Tall + Symboler + Passordhint + Registrering + Lagre + Sikkerhetskode + Velg chiffertype + Tjeneradresse + Velg en tjener + Legg til selvtjent + Offisiell + Innstillinger + Konto + Utseende + Sikkerhet + Send inn + Drakt + Nattsvart + Mørk + Lys + System + Umiddelbart + Aldri + Navn + Skriv inn din e-postadresse + Et passordhint ble sendt til e-postadressen + Bekreft din e-postadresse + Lås opp + Lås opp med biometri + Brukernavn + Hvelv + Tidsavbrudd for hvelv + Vis + Nettadresse + Nettside + Velkommen til LibrePass + Valgfritt + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000..6b29a040 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,125 @@ + + + + %d minuta + %d minuty + %d minut + %d minut + + + %d godzina + %d godziny + %d godzin + %d godzin + + Dodaj + Dodaj pole + Dodaj nowy szyfr + Dodaj serwer + Anuluj + Włącz uwierzytelnianie biometryczne + Konfiguracja + Użyj hasła + Prosimy o uwierzytelnienie + Odblokuj + Numer karty + Nazwa posiadacza karty + Zmień hasło + Dane karty + Dane logowania + Bezpieczna notatka + Potwierdź nowe hasło + Potwierdź hasło + Usuń + Usuń konto + Edytuj + E-mail + Błąd szyfrowania/deszyfrowania + Nieprawidłowe dane uwierzytelniające + Nieprawidłowy adres e-mail + Hasło jest nieprawidłowe + Hasło jest zbyt krótkie + Hasła nie są zgodne + Nieznany błąd + Brak połączenia z internetem + Miesiąc ważności + Rok ważności + Uzyskaj podpowiedź do hasła + Zablokuj sejf + Zaloguj się + Dane logowania + Wyloguj się + Nazwa + Nowe hasło + Notatka + Stare hasło + Pozostałe informacje + Hasło + Generator hasła + Duże litery + Długość + Liczby + Symbole + Podpowiedź dotycząca hasła + Zarejestruj się + Zapisz + Kod bezpieczeństwa + Wybierz typ szyfru + Adres serwera + Wybierz serwer + Dodaj samodzielnie hostowany + Oficjalny + Ustawienia + Konto + Wygląd + Bezpieczeństwo + Potwierdź + Motyw + Czarny + Ciemny + Jasny + Domyślne ustawienia systemu + Natychmiastowy + Nigdy + Tytuł + Wprowadź swój adres e-mail + Podpowiedź dotycząca hasła została wysłana na adres e-mail + Zweryfikuj swój adres e-mail + Brak połączenia z serwerem + Odblokuj + Odblokowanie za pomocą danych biometrycznych + Nazwa użytkownika + Sejf + Limit czasu sejfu + Zobacz + Adres strony + Strona internetowa + Witaj w LibrePass + Opcjonalnie + Uwierzytelnianie dwuetapowe + Sekret dwuetapowego logowania + Adres URI jest nieprawidłowy + Dane karty + Klucz biometryczny został unieważniony przez Androida + Nowy adres e-mail + Zmień adres e-mail + Skonfiguruj 2fa + Zeskanuj kod QR + Wprowadź klucz manualnie + Usuń 2fa + Typ + Algorytm + Cyfry + Okres + Licznik + Nieznany błąd serwera bazy danych + Zduplikowano + Nie odnaleziono kolekcji + Nieprawidłowe zapytanie API + Nieprawidłowy token logowania + Nie odnaleziono narzędzia Cipher + Nieprawidłowe dane uwierzytelniające + Zweryfikuj swój adres e-mail + Zbyt wiele zapytań + Błąd zewnętrznego serwera - Nie udało się wysłać wiadomości e-mail + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..e3c890f1 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,121 @@ + + + + %d dakika + %d dakika + + + %d saat + %d saat + + Ekle + Alan ekle + Yeni şifre ekle + Sunucu ekle + İki faktörlü kimlik doğrulama + İki faktör sırrı + İptal + Biyometrik kimlik doğrulamayı aç + Kurulum + Şifre kullan + Lütfen kimliğinizi doğrulayın + Kilidi aç + Kart numarası + Kart sahibinin adı + Şifreyi değiştir + Kart verileri + Oturum açma verileri + Güvenli not + Yeni şifreyi onayla + Şifreyi onayla + Sil + Hesabı sil + Düzenle + E-posta + Şifreleme/Şifre çözme hatası + Geçersiz kimlik bilgileri + Geçersiz e-posta adresi + Şifre yanlış + Şifre çok kısa + Şifreler eşleşmiyor + Bilinmeyen hata + Son kullanım ayı + Son kullanım yılı + Şifre ipucunu al + Kasayı kilitle + Oturum aç + Oturum açma bilgileri + Çıkış yap + Ad + Yeni şifre + Notlar + Eski şifre + Diğer bilgiler + Şifre + Şifre oluşturucu + Büyük harfler + Uzunluk + Sayılar + Semboller + Parola ipucu + Kaydol + Kaydet + Güvenlik kodu + Şifre türünü seç + Sunucu adresi + Bir sunucu seç + Kendi kendine barındırılanı ekle + Resmi + Ayarlar + Hesap + Görünüm + Güvenlik + Başvur + Tema + Siyah + Koyu + Açık + Sistem varsayılanı + Anında + Asla + Başlık + E-posta adresinizi girin + E-posta adresine bir şifre ipucu gönderildi + Lütfen e-posta adresinizi doğrulayın + Kilidi aç + Biyometri ile kilidi aç + Kullanıcı adı + Kasa + Kasa zaman aşımı + Görüntüle + Web sitesi adresi + Web sitesi + LibrePass\'a Hoş Geldiniz + İsteğe bağlı + URI adresi geçersiz + Kart bilgileri + Biyometrik anahtar Android tarafından geçersiz kılındı + Yeni e-posta adresi + E-posta adresini değiştirin + Şifre bulunamadı + Koleksiyon bulunamadı + Bilinmeyen sunucu veritabanı hatası + Çoğaltıldı + Lütfen e-posta adresinizi doğrulayın + Geçersiz API isteği + Geçersiz kimlik bilgileri + Geçersiz oturum açma anahtarı + Çok fazla istek + Dahili sunucu hatası - E-posta gönderilemedi + İki faktörü yapılandır + QR kodunu tara + Tuşu manuel olarak gir + İki faktörü sil + Tür + Algoritma + Rakamlar + Dönem + Sayaç + Sunucu ile bağlantı yok + İnternet bağlantısı yok + \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000..cddc5ecd --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,45 @@ + + + + %d giờ + + Thêm mật mã mới + Thêm máy chủ + Bí mật hai yếu tố + Hủy bỏ + Thiết lập + Sử dụng mật khẩu + Số thẻ + Ghi chú an toàn + Mật khẩu quá ngắn + Lỗi không rõ + Thông tin thêm về đăng nhập + Chào mừng đến với LibrePass + Đăng ký + Dữ liệu đăng nhập + Đăng nhập + Đăng xuất + + %d phút + + Thêm + Thêm trường + Xác thực hai yếu tố + Bật xác thực sinh trắc học + Vui lòng xác thực chính bạn + Mở khóa + Tên chủ thẻ + Đổi mật khẩu + Dữ liệu thẻ + Xác nhận mật khẩu mới + Địa chỉ e-mail không hợp lệ + Xác nhận mật khẩu + Xóa bỏ + Xóa tài khoản + Biên tập + Lỗi mã hóa/giải mã + Thông tin không hợp lệ + Mật khẩu không đúng + Mật khẩu không khớp + Tháng hết hạn + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66359dc2..a37068cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,125 @@ LibrePass - + Material You (Android 12+) + + %d minute + %d minutes + + + %d hour + %d hours + + Add + Add field + Add new cipher + Add server + Two-factor authentication + Two-factor secret + Cancel + Turn on biometric authentication + Setup + Use password + Please authenticate yourself + Unlock + Card number + Cardholder name + Change password + Card data + Login data + Secure note + Confirm new password + Confirm password + Delete + Delete account + Edit + E-mail + Encryption/Decryption error + Invalid credentials + Invalid e-mail address + Password is incorrect + Password is too short + Passwords don\'t match + Unknown error + No internet connection + Expiration month + Expiration year + Get password hint + Lock vault + Log in + Login details + Log out + Name + New password + Notes + Old password + Other details + Password + Password generator + Capital letters + Length + Numbers + Symbols + Password hint + Register + Save + Security code + Select cipher type + Server address + Choose a server + Add self-hosted + Official + Settings + Account + Manage your account + Appearance + Security + Manage application security options + Submit + Theme + Black + Dark + Light + System default + Instant + Never + Title + Enter your e-mail address + A password hint was sent to the email address + Please verify your e-mail address + No connection with the server + Unlock + Unlock with biometrics + Username + Vault + Vault timeout + View + Website address + Website + Welcome to LibrePass + optional + URI address is invalid + Card details + Biometric key has been invalidated by Android + New e-mail address + Change e-mail address + Cipher not found + Collection not found + Unknown server database error + Duplicated + Please verify your e-mail address + Invalid API request + Invalid credentials + Invalid login token + Too many requests + Internal server error - Failed to send email + Configure two-factor + Scan QR code + Enter key manually + Delete two-factor + Type + Algorithm + Digits + Period + Counter + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index b2718f6a..a20dd231 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,8 +3,6 @@ plugins { // trick: for the same plugin versions in all sub-modules alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.android.screenshot) apply false - alias(libs.plugins.androidx.room) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.dagger.hilt) apply false alias(libs.plugins.kotlin.android) apply false diff --git a/database-logic/build.gradle.kts b/database-logic/build.gradle.kts index 4d1ec76f..6a42c20c 100644 --- a/database-logic/build.gradle.kts +++ b/database-logic/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.androidx.room) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.serialization) @@ -39,7 +38,3 @@ dependencies { implementation(libs.medzik.android.crypto) implementation(libs.librepass.client) } - -room { - schemaDirectory("$projectDir/schemas") -} diff --git a/database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/2.json b/database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/2.json deleted file mode 100644 index 3d426d4c..00000000 --- a/database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/2.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "73badfa2c28a621e25ed24a57bcd404f", - "entities": [ - { - "tableName": "Credentials", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` BLOB NOT NULL, `email` TEXT NOT NULL, `apiUrl` TEXT, `apiKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `lastSync` INTEGER, `memory` INTEGER NOT NULL, `iterations` INTEGER NOT NULL, `parallelism` INTEGER NOT NULL, `biometricAesKey` TEXT, `biometricAesKeyIV` TEXT, `biometricReSetup` INTEGER NOT NULL, PRIMARY KEY(`userId`))", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "apiUrl", - "columnName": "apiUrl", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "apiKey", - "columnName": "apiKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "publicKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastSync", - "columnName": "lastSync", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "memory", - "columnName": "memory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "iterations", - "columnName": "iterations", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "parallelism", - "columnName": "parallelism", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "biometricAesKey", - "columnName": "biometricAesKey", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "biometricAesKeyIV", - "columnName": "biometricAesKeyIV", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "biometricReSetup", - "columnName": "biometricReSetup", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "LocalCipher", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `owner` BLOB NOT NULL, `needUpload` INTEGER NOT NULL, `encryptedCipher` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "owner", - "columnName": "owner", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "needUpload", - "columnName": "needUpload", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "encryptedCipher", - "columnName": "encryptedCipher", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '73badfa2c28a621e25ed24a57bcd404f')" - ] - } -} \ No newline at end of file diff --git a/database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/3.json b/database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/3.json deleted file mode 100644 index 19a7698a..00000000 --- a/database-logic/schemas/dev.medzik.librepass.android.database.LibrePassDatabase/3.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "1da40d12ffe0462099dfb9a60be347db", - "entities": [ - { - "tableName": "Credentials", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` BLOB NOT NULL, `email` TEXT NOT NULL, `apiUrl` TEXT, `apiKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `lastSync` INTEGER, `memory` INTEGER NOT NULL, `iterations` INTEGER NOT NULL, `parallelism` INTEGER NOT NULL, `biometricAesKey` TEXT, `biometricAesKeyIV` TEXT, `biometricReSetup` INTEGER NOT NULL, PRIMARY KEY(`userId`))", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "apiUrl", - "columnName": "apiUrl", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "apiKey", - "columnName": "apiKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "publicKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastSync", - "columnName": "lastSync", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "memory", - "columnName": "memory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "iterations", - "columnName": "iterations", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "parallelism", - "columnName": "parallelism", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "biometricAesKey", - "columnName": "biometricAesKey", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "biometricAesKeyIV", - "columnName": "biometricAesKeyIV", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "biometricReSetup", - "columnName": "biometricReSetup", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "LocalCipher", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `owner` BLOB NOT NULL, `needUpload` INTEGER NOT NULL, `encryptedCipher` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "owner", - "columnName": "owner", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "needUpload", - "columnName": "needUpload", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "encryptedCipher", - "columnName": "encryptedCipher", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "CustomServer", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`address`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "address", - "columnName": "address", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "address" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1da40d12ffe0462099dfb9a60be347db')" - ] - } -} \ No newline at end of file diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/CredentialsDao.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/CredentialsDao.kt index 99ba4ebe..0f3212c2 100644 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/CredentialsDao.kt +++ b/database-logic/src/main/java/dev/medzik/librepass/android/database/CredentialsDao.kt @@ -5,6 +5,9 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Update +/** + * Data access object for [Credentials]. + */ @Dao interface CredentialsDao { /** diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServer.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServer.kt deleted file mode 100644 index eeaa69cd..00000000 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServer.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.medzik.librepass.android.database - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity -data class CustomServer( - val name: String, - @PrimaryKey - val address: String -) diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServerDao.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServerDao.kt deleted file mode 100644 index 78141484..00000000 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/CustomServerDao.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.medzik.librepass.android.database - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query - -@Dao -interface CustomServerDao { - @Query("SELECT * FROM customserver") - fun getAll(): List - - @Insert - suspend fun insert(customServer: CustomServer) -} diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/Database.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/Database.kt index af5dfb6e..a7970b1c 100644 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/Database.kt +++ b/database-logic/src/main/java/dev/medzik/librepass/android/database/Database.kt @@ -1,18 +1,15 @@ package dev.medzik.librepass.android.database -import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase @Database( - version = 3, - entities = [Credentials::class, LocalCipher::class, CustomServer::class], - autoMigrations = [ - AutoMigration(from = 2, to = 3) - ] + version = 2, + entities = [Credentials::class, LocalCipher::class], + exportSchema = false ) abstract class LibrePassDatabase : RoomDatabase() { abstract fun credentialsDao(): CredentialsDao + abstract fun cipherDao(): LocalCipherDao - abstract fun customServerDao(): CustomServerDao } diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/DatabaseProvider.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/DatabaseProvider.kt index 4f8484fe..70ba746e 100644 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/DatabaseProvider.kt +++ b/database-logic/src/main/java/dev/medzik/librepass/android/database/DatabaseProvider.kt @@ -1,14 +1,19 @@ package dev.medzik.librepass.android.database import android.content.Context -import androidx.room.AutoMigration import androidx.room.Room +/** + * Database provider singleton class. + */ object DatabaseProvider { private var database: LibrePassDatabase? = null /** - * Get database instance. If the database is not initialized, it will be initialize. + * Get database instance. If database is not initialized, it will be initialize. + * + * @param context application context + * @return Database instance. */ fun getInstance(context: Context): LibrePassDatabase { if (database == null) { diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/LocalCipherDao.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/LocalCipherDao.kt index c5ddd94f..935aab30 100644 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/LocalCipherDao.kt +++ b/database-logic/src/main/java/dev/medzik/librepass/android/database/LocalCipherDao.kt @@ -3,6 +3,9 @@ package dev.medzik.librepass.android.database import androidx.room.* import java.util.* +/** + * Data access object for [LocalCipher]. + */ @Dao interface LocalCipherDao { /** diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/Repository.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/Repository.kt index 92ee40fd..e3e6ad41 100644 --- a/database-logic/src/main/java/dev/medzik/librepass/android/database/Repository.kt +++ b/database-logic/src/main/java/dev/medzik/librepass/android/database/Repository.kt @@ -8,7 +8,6 @@ import android.content.Context interface RepositoryInterface { val credentials: CredentialsDao val cipher: LocalCipherDao - val customServer: CustomServerDao } /** @@ -20,5 +19,4 @@ class Repository(context: Context) : RepositoryInterface { override val credentials = database.credentialsDao() override val cipher = database.cipherDao() - override val customServer = database.customServerDao() } diff --git a/database-logic/src/main/java/dev/medzik/librepass/android/database/datastore/CustomServers.kt b/database-logic/src/main/java/dev/medzik/librepass/android/database/datastore/CustomServers.kt new file mode 100644 index 00000000..91486b89 --- /dev/null +++ b/database-logic/src/main/java/dev/medzik/librepass/android/database/datastore/CustomServers.kt @@ -0,0 +1,48 @@ +package dev.medzik.librepass.android.database.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import kotlinx.coroutines.flow.first +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.InputStream +import java.io.OutputStream + +@Serializable +data class CustomServers( + val name: String, + val address: String +) + +private object CustomServersSerializer : Serializer> { + override val defaultValue = emptyList() + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun readFrom(input: InputStream): List { + return Json.decodeFromStream(input) + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun writeTo( + t: List, + output: OutputStream + ) = Json.encodeToStream(t, output) +} + +private val Context.customServersDataStore: DataStore> by dataStore( + fileName = "customServers.pb", + serializer = CustomServersSerializer +) + +suspend fun readCustomServers(context: Context): List { + return context.customServersDataStore.data.first() +} + +suspend fun writeCustomServers(context: Context, preference: List) { + context.customServersDataStore.updateData { preference } +} diff --git a/gradle.properties b/gradle.properties index a34f7f38..7edc7334 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,3 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false -android.experimental.enableScreenshotTest=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5bff9b9f..dc1a0024 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,6 @@ [versions] accompanist = "0.34.0" agp = "8.5.0" -android-screenshot = "0.0.1-alpha02" android-sdk-compile = "34" android-sdk-min = "24" android-sdk-target = "34" @@ -12,9 +11,9 @@ androidx-datastore = "1.1.1" androidx-room = "2.6.1" coil-compose = "2.6.0" compose = "1.6.8" -compose-lifecycle-runtime = "2.8.3" +compose-lifecycle-runtime = "2.8.2" compose-material3 = "1.2.1" -compose-navigation = "2.8.0-beta04" +compose-navigation = "2.8.0-beta03" dagger = "2.51.1" google-material = "1.12.0" hilt = "1.2.0" @@ -23,7 +22,7 @@ kotlin-ksp = "2.0.0-1.0.22" kotlinx-coroutines = "1.8.1" kotlinx-serialization = "1.7.0" librepass-client = "1.6.2" -medzik-android-utils = "1.7.1" +medzik-android-utils = "1.6.1" otp = "1.0.1" zxing = "3.5.3" zxing-android = "4.3.0" @@ -63,8 +62,6 @@ zxing-android = { module = "com.journeyapps:zxing-android-embedded", version.ref [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -android-screenshot = { id = "com.android.compose.screenshot", version.ref = "android-screenshot"} -androidx-room = { id = "androidx.room", version.ref = "androidx-room" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 683d8a40..eea2d9d7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,5 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":common") -include(":ui-logic") include(":database-logic") include(":business-logic") diff --git a/ui-logic/.gitignore b/ui-logic/.gitignore deleted file mode 100644 index 796b96d1..00000000 --- a/ui-logic/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/ui-logic/build.gradle.kts b/ui-logic/build.gradle.kts deleted file mode 100644 index dc5e9f8c..00000000 --- a/ui-logic/build.gradle.kts +++ /dev/null @@ -1,70 +0,0 @@ -plugins { - alias(libs.plugins.android.library) -// alias(libs.plugins.android.screenshot) - alias(libs.plugins.compose.compiler) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.ksp) - alias(libs.plugins.kotlin.serialization) -} - -android { - namespace = "dev.medzik.librepass.android.ui" - compileSdk = libs.versions.android.sdk.compile.get().toInt() - - defaultConfig { - minSdk = libs.versions.android.sdk.min.get().toInt() - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - buildFeatures { - compose = true - } - - experimentalProperties["android.experimental.enableScreenshotTest"] = true -} - -dependencies { - implementation(libs.accompanist.drawablepainter) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.core.ktx) - implementation(libs.compose.lifecycle.runtime) - implementation(libs.compose.material.icons) - implementation(libs.compose.material3) - implementation(libs.compose.navigation) - implementation(libs.compose.navigation) - implementation(libs.compose.ui) - implementation(libs.kotlinx.coroutines) - implementation(libs.kotlinx.serialization.json) - implementation(libs.medzik.android.compose) - implementation(libs.medzik.android.crypto) - implementation(libs.medzik.android.utils) - - implementation(libs.librepass.client) - - implementation(libs.dagger.hilt) - implementation(libs.hilt.navigation.compose) - ksp(libs.dagger.hilt.compiler) - - implementation(projects.common) - implementation(projects.databaseLogic) - implementation(projects.businessLogic) - - // for testing - debugImplementation(libs.compose.ui.test.manifest) - - // for preview support - debugImplementation(libs.compose.ui.tooling) - implementation(libs.compose.ui.tooling.preview) - -// screenshotTestImplementation(libs.compose.ui.tooling) -} diff --git a/ui-logic/src/main/AndroidManifest.xml b/ui-logic/src/main/AndroidManifest.xml deleted file mode 100644 index 9a40236b..00000000 --- a/ui-logic/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/Home.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/Home.kt deleted file mode 100644 index da928ebd..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/Home.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.medzik.librepass.android.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavController -import dev.medzik.librepass.android.database.injection.RoomModule -import dev.medzik.librepass.android.ui.vault.VaultHome -import dev.medzik.librepass.android.ui.vault.VaultHomeScreen -import kotlinx.serialization.Serializable - -@Serializable -object Home - -@Composable -fun HomeScreen(navController: NavController) { - val context = LocalContext.current - val repository = RoomModule.providesRepository(context) - val credentials = repository.credentials.get() - - if (credentials != null) { - val args = VaultHome( - credentials = credentials - ) - - VaultHomeScreen(args, navController) - } else { - WelcomeScreen(navController) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt deleted file mode 100644 index 0d3c2126..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.medzik.librepass.android.ui - -import androidx.compose.foundation.layout.imePadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import dev.medzik.android.compose.navigation.NavigationAnimations -import dev.medzik.librepass.android.ui.auth.authGraph - -@Composable -fun LibrePassNavigation() { - val navController = rememberNavController() - - NavHost( - navController, - startDestination = Home, - modifier = Modifier.imePadding(), - enterTransition = { - NavigationAnimations.enterTransition() - }, - exitTransition = { - NavigationAnimations.exitTransition() - }, - popEnterTransition = { - NavigationAnimations.popEnterTransition() - }, - popExitTransition = { - NavigationAnimations.popExitTransition() - } - ) { - composable { - HomeScreen(navController) - } - - authGraph(navController) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/WelcomeScreen.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/WelcomeScreen.kt deleted file mode 100644 index 475312ca..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/WelcomeScreen.kt +++ /dev/null @@ -1,268 +0,0 @@ -package dev.medzik.librepass.android.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.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.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Password -import androidx.compose.material.icons.filled.SyncLock -import androidx.compose.material.icons.filled.VpnLock -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.medzik.android.compose.ui.IconBox -import dev.medzik.librepass.android.ui.auth.Login -import dev.medzik.librepass.android.ui.auth.Signup -import kotlinx.coroutines.launch -import kotlin.math.absoluteValue - -@Composable -fun WelcomeScreen(navController: NavController) { - Scaffold { innerPadding -> - LazyColumn( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - item {} - - item { - WelcomePager() - } - - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Button( - modifier = Modifier - .padding(vertical = 8.dp) - .height(50.dp) - .fillMaxWidth(0.75f), - onClick = { navController.navigate(Signup) } - ) { - Text( - text = stringResource(R.string.Signup), - style = MaterialTheme.typography.titleMedium - ) - } - - Button( - modifier = Modifier - .padding(vertical = 8.dp) - .height(50.dp) - .fillMaxWidth(0.75f), - colors = ButtonDefaults.buttonColors().copy( - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary, - ), - onClick = { navController.navigate(Login) } - ) { - Text( - text = stringResource(R.string.Login), - style = MaterialTheme.typography.titleMedium - ) - } - } - } - } - } -} - -@Composable -private fun WelcomePager() { - val scope = rememberCoroutineScope() - - val pagerState = rememberPagerState( - pageCount = { 3 } - ) - - Column( - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize() - ) { page -> - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Card( - modifier = Modifier - .padding(horizontal = 12.dp) - .height(300.dp) - .graphicsLayer { - // Calculate the absolute offset for the current page from the - // scroll position. We use the absolute value which allows us to mirror - // any effects for both directions - val pageOffset = ( - (pagerState.currentPage - page) + pagerState - .currentPageOffsetFraction - ).absoluteValue - - // We animate the alpha, between 50% and 100% - alpha = lerp( - start = 0.5f, - stop = 1f, - fraction = 1f - pageOffset.coerceIn(0f, 1f) - ) - }, - shape = MaterialTheme.shapes.large - ) { - Column( - modifier = Modifier - .padding(24.dp) - .fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (page) { - 0 -> WelcomeFirstPager() - 1 -> WelcomeSecondPager() - 2 -> WelcomeThirdPager() - } - } - } - } - } - - Row( - modifier = Modifier - .wrapContentHeight() - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - repeat(pagerState.pageCount) { iteration -> - val color = if (pagerState.currentPage == iteration) { - MaterialTheme.colorScheme.primary - } else MaterialTheme.colorScheme.surfaceVariant - - Box( - modifier = Modifier - .padding(2.dp) - .clip(CircleShape) - .background(color) - .size(16.dp) - .clickable { scope.launch { pagerState.animateScrollToPage(iteration) } } - ) - } - } - } -} - -@Composable -private fun WelcomeFirstPager() { - IconBox( - imageVector = Icons.Default.VpnLock, - modifier = Modifier.size(128.dp) - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - text = stringResource(R.string.Welcome_FirstCardTitle), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - text = stringResource(R.string.Welcome_FirstCardSubtitle), - textAlign = TextAlign.Center - ) -} - -@Composable -private fun WelcomeSecondPager() { - IconBox( - imageVector = Icons.Default.Password, - modifier = Modifier.size(128.dp) - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - text = stringResource(R.string.Welcome_PasswordGeneratorCardTitle), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - text = stringResource(R.string.Welcome_PasswordGeneratorSubtitle), - textAlign = TextAlign.Center - ) -} - -@Composable -private fun WelcomeThirdPager() { - IconBox( - imageVector = Icons.Default.SyncLock, - modifier = Modifier.size(128.dp) - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - text = stringResource(R.string.Welcome_SyncCardTitle), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - text = stringResource(R.string.Welcome_PasswordGeneratorSubtitle), - textAlign = TextAlign.Center - ) -} - -@Preview -@Composable -fun HomeScreenPreview() { - HomeScreen(rememberNavController()) -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/AuthGraph.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/AuthGraph.kt deleted file mode 100644 index 0264cf1a..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/AuthGraph.kt +++ /dev/null @@ -1,21 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute - -fun NavGraphBuilder.authGraph(navController: NavController) { - composable { - SignupScreen(navController) - } - - composable { - LoginScreen(navController) - } - - composable { - val args = it.toRoute() - ForgotPasswordScreen(navController, args) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ChoiceServer.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ChoiceServer.kt deleted file mode 100644 index b58ca392..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ChoiceServer.kt +++ /dev/null @@ -1,215 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import dev.medzik.android.compose.rememberMutable -import dev.medzik.android.compose.theme.spacing -import dev.medzik.android.compose.ui.IconBox -import dev.medzik.android.compose.ui.LoadingButton -import dev.medzik.android.compose.ui.bottomsheet.BaseBottomSheet -import dev.medzik.android.compose.ui.bottomsheet.BottomSheetState -import dev.medzik.android.compose.ui.bottomsheet.PickerBottomSheet -import dev.medzik.android.compose.ui.bottomsheet.rememberBottomSheetState -import dev.medzik.android.compose.ui.textfield.AnimatedTextField -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.android.utils.showToast -import dev.medzik.librepass.android.database.CustomServer -import dev.medzik.librepass.android.database.injection.RoomModule -import dev.medzik.librepass.android.ui.R -import dev.medzik.librepass.client.Server -import dev.medzik.librepass.client.api.checkApiConnection -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChoiceServer(server: MutableState) { - val context = LocalContext.current - - val scope = rememberCoroutineScope() - - val choiceServerSheet = rememberBottomSheetState() - val addServerSheet = rememberBottomSheetState() - - Button( - onClick = { choiceServerSheet.show() }, - colors = ButtonDefaults.buttonColors().copy( - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary - ), - shape = MaterialTheme.shapes.medium - ) { - Row { - Text( - text = stringResource(R.string.SelectServer), - style = MaterialTheme.typography.bodyMedium - ) - } - } - - var servers by rememberMutable(emptyList()) - - LaunchedEffect(Unit) { - val repository = RoomModule.providesRepository(context) - - val customServers = repository.customServer.getAll() - - servers = listOf( - CustomServer( - name = context.getString(R.string.Official), - address = Server.PRODUCTION - ), - *customServers.toTypedArray(), - CustomServer( - context.getString(R.string.AddCustomServer), - "custom_server" - ) - ) - } - - PickerBottomSheet( - state = choiceServerSheet, - items = servers, - onSelected = { - if (it.address == "custom_server") { - addServerSheet.show() - } else { - server.value = it.address - } - }, - onDismiss = { - scope.launch { choiceServerSheet.hide() } - } - ) { - val color = if (it.address == server.value) { - MaterialTheme.colorScheme.primary - } else Color.Unspecified - - Text( - text = it.name, - modifier = Modifier - .padding(vertical = MaterialTheme.spacing.medium) - .fillMaxWidth(), - color = color, - fontWeight = if (it.address == server.value) FontWeight.Bold else null - ) - } - - BaseBottomSheet( - state = addServerSheet, - onDismiss = { - scope.launch { addServerSheet.hide() } - } - ) { - AddServerSheetContent( - addServerSheet, - server - ) - } -} - -@Composable -fun AddServerSheetContent( - sheetState: BottomSheetState, - server: MutableState -) { - val context = LocalContext.current - - val scope = rememberCoroutineScope() - - val repository = RoomModule.providesRepository(context) - - var customServer by rememberMutable( - CustomServer( - name = "", - address = "https://" - ) - ) - - Column( - modifier = Modifier.padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - AnimatedTextField( - label = stringResource(R.string.Name), - value = TextFieldValue( - value = customServer.name, - onChange = { customServer = customServer.copy(name = it) } - ), - clearButton = true, - leading = { - IconBox(Icons.Default.Draw) - } - ) - - AnimatedTextField( - label = stringResource(R.string.ServerAddress), - value = TextFieldValue( - value = customServer.address, - onChange = { customServer = customServer.copy(address = it) } - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri - ), - clearButton = true, - leading = { - IconBox(Icons.Default.Dns) - } - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - var loading by rememberMutable(false) - - LoadingButton( - loading = loading, - onClick = { - scope.launch(Dispatchers.IO) { - loading = true - - if (!checkApiConnection(customServer.address)) { - context.showToast(R.string.NoServerConnection) - loading = false - return@launch - } - - repository.customServer.insert(customServer) - - server.value = customServer.address - - sheetState.hide() - } - } - ) { - Text(stringResource(R.string.Add)) - } - } - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordScreen.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordScreen.kt deleted file mode 100644 index 35e13cae..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordScreen.kt +++ /dev/null @@ -1,128 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -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.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -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 androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.medzik.android.compose.icons.TopAppBarBackIcon -import dev.medzik.android.compose.theme.infoContainer -import dev.medzik.android.compose.ui.LoadingButton -import dev.medzik.librepass.android.ui.R -import dev.medzik.librepass.client.Server -import dev.medzik.librepass.client.api.AuthClient -import kotlinx.serialization.Serializable - -@Serializable -data class ForgotPassword( - val server: String -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ForgotPasswordScreen( - navController: NavController, - args: ForgotPassword -) { - Scaffold( - topBar = { - TopAppBar( - title = { - Text(stringResource(R.string.ForgotPassword)) - }, - navigationIcon = { - TopAppBarBackIcon(navController) - } - ) - } - ) { innerPadding -> - ForgotPasswordScreenContent(args, innerPadding) - } -} - -@Composable -fun ForgotPasswordScreenContent( - args: ForgotPassword, - innerPadding: PaddingValues, - viewModel: ForgotPasswordViewModel = hiltViewModel() -) { - val context = LocalContext.current - - LaunchedEffect(Unit) { - viewModel.setServer(args.server) - } - - Column( - modifier = Modifier - .padding(innerPadding) - .padding(horizontal = 12.dp) - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.infoContainer, - shape = MaterialTheme.shapes.large - ) { - Text( - modifier = Modifier.padding(12.dp), - text = stringResource(R.string.ForgotPassword_Info) - ) - } - - EmailTextField(viewModel.email) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - LoadingButton( - modifier = Modifier - .padding(vertical = 12.dp) - .height(50.dp) - .fillMaxWidth(0.85f), - onClick = { viewModel.requestPasswordHint(context) } - ) { - Text( - text = stringResource(R.string.GetPasswordHint), - style = MaterialTheme.typography.titleMedium - ) - } - } - } -} - -@Preview -@Composable -fun ForgotPasswordScreenPreview() { - MaterialTheme { - ForgotPasswordScreen( - navController = rememberNavController(), - args = ForgotPassword(Server.PRODUCTION) - ) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordViewModel.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordViewModel.kt deleted file mode 100644 index db516ada..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/ForgotPasswordViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import android.content.Context -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.android.utils.showToast -import dev.medzik.librepass.android.ui.R -import dev.medzik.librepass.client.api.AuthClient - -class ForgotPasswordViewModel : ViewModel() { - var email = TextFieldValue.fromMutableState() - private lateinit var authClient: AuthClient - - fun requestPasswordHint(context: Context) { - if (email.value.isEmpty()) { - context.showToast(context.getString(R.string.EnterEmail)) - return - } - - try { - authClient.requestPasswordHint(email.value) - - context.showToast(context.getString(R.string.PasswordHintWasSent)) - } catch (e: Exception) { -// e.showErrorToast(context) - } - } - - fun setServer(server: String) { - AuthClient(apiUrl = server) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginScreen.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginScreen.kt deleted file mode 100644 index 5d43c43a..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginScreen.kt +++ /dev/null @@ -1,141 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -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.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -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.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.medzik.android.compose.icons.TopAppBarBackIcon -import dev.medzik.android.compose.ui.LoadingButton -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.librepass.android.ui.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable - -@Serializable -object Login - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LoginScreen(navController: NavController) { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - TopAppBarBackIcon(navController) - }, - title = { - Text(stringResource(R.string.Login)) - } - ) - } - ) { innerPadding -> - LoginScreenContent(navController, innerPadding) - } -} - -@Composable -fun LoginScreenContent( - navController: NavController, - innerPadding: PaddingValues, - viewModel: LoginViewModel = hiltViewModel() -) { - val context = LocalContext.current - - LazyColumn( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - item { - Column( - modifier = Modifier.padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - EmailTextField( - TextFieldValue.fromMutableState( - state = viewModel.email - ) - ) - PasswordTextField( - TextFieldValue.fromMutableState( - state = viewModel.password - ) - ) - - ChoiceServer(viewModel.server) - } - } - - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - LoadingButton( - modifier = Modifier - .padding(vertical = 12.dp) - .height(50.dp) - .fillMaxWidth(0.85f), - onClick = { viewModel.login(context) }, - enabled = viewModel.email.value.isNotEmpty() && viewModel.email.value.isNotEmpty() - ) { - Text( - text = stringResource(R.string.Login), - style = MaterialTheme.typography.titleMedium - ) - } - - OutlinedButton( - modifier = Modifier - .padding(vertical = 12.dp) - .height(45.dp) - .fillMaxWidth(0.75f), - onClick = { - navController.navigate( - ForgotPassword( - server = viewModel.server.value - ) - ) - } - ) { - Text( - text = stringResource(R.string.ForgotPassword), - style = MaterialTheme.typography.titleSmall - ) - } - } - } - } -} - -@Preview -@Composable -fun LoginScreenPreview() { - MaterialTheme { - LoginScreen(rememberNavController()) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginViewModel.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginViewModel.kt deleted file mode 100644 index 03e2477c..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/LoginViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import android.content.Context -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.android.utils.showToast -import dev.medzik.librepass.android.business.injection.VaultCacheModule -import dev.medzik.librepass.android.common.haveNetworkConnection -import dev.medzik.librepass.android.database.Credentials -import dev.medzik.librepass.android.database.injection.RoomModule -import dev.medzik.librepass.android.ui.R -import dev.medzik.librepass.client.Server -import dev.medzik.librepass.client.api.AuthClient -import dev.medzik.librepass.utils.fromHex - -class LoginViewModel : ViewModel() { - var email = mutableStateOf("") - var password = mutableStateOf("") - var server = mutableStateOf(Server.PRODUCTION) - - private val authClient = AuthClient(apiUrl = server.value) - - suspend fun login(context: Context) { - if (!context.haveNetworkConnection()) { - context.showToast(R.string.NoInternetConnection) - return - } - - try { - val preLogin = authClient.preLogin(email.value) - val credentials = authClient.login( - email = email.value, - password = password.value - ) - - val repository = RoomModule.providesRepository(context) - val vaultCache = VaultCacheModule.providesVault(repository.cipher) - - val credentialsDbEntry = Credentials( - userId = credentials.userId, - email = email.value, - apiUrl = if (server.value == Server.PRODUCTION) null else server.value, - apiKey = credentials.apiKey, - publicKey = credentials.publicKey, - memory = preLogin.memory, - iterations = preLogin.iterations, - parallelism = preLogin.parallelism, - biometricReSetup = true - ) - repository.credentials.insert(credentialsDbEntry) - - vaultCache.aesKey = credentials.aesKey.fromHex() - } catch (e: Exception) { - // TODO - } - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupScreen.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupScreen.kt deleted file mode 100644 index b34f92fb..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupScreen.kt +++ /dev/null @@ -1,175 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -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.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.InputTransformation.Companion.keyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Password -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -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.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.medzik.android.compose.icons.TopAppBarBackIcon -import dev.medzik.android.compose.theme.regularHorizontalPadding -import dev.medzik.android.compose.ui.IconBox -import dev.medzik.android.compose.ui.LoadingButton -import dev.medzik.android.compose.ui.textfield.AnimatedTextField -import dev.medzik.android.compose.ui.textfield.PasswordAnimatedTextField -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.librepass.android.ui.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable - -@Serializable -object Signup - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SignupScreen( - navController: NavController, - viewModel: SignupViewModel = hiltViewModel() -) { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - TopAppBarBackIcon(navController) - }, - title = { - Text(stringResource(R.string.Signup)) - } - ) - } - ) { innerPadding -> - SignupScreenContent(navController, innerPadding, viewModel) - } -} - -@Composable -fun SignupScreenContent( - navController: NavController, - innerPadding: PaddingValues, - viewModel: SignupViewModel -) { - val context = LocalContext.current - - LazyColumn( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - item { - Column( - modifier = Modifier.regularHorizontalPadding(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - EmailTextField( - value = TextFieldValue.fromMutableState( - state = viewModel.email - ) - ) - - PasswordAnimatedTextField( - label = stringResource(R.string.Password), - value = TextFieldValue.fromMutableState( - state = viewModel.password, - valueLabel = TextFieldValue.ValueLabel( - type = TextFieldValue.ValueLabel.Type.WARNING, - text = context.getString(R.string.PasswordLabel_WontBeRecover) - ) - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email - ) - ) - - PasswordAnimatedTextField( - label = stringResource(R.string.RetypePassword), - value = TextFieldValue.fromMutableState( - state = viewModel.retypePassword, - error = if (!viewModel.retypePasswordIsValid) { - "Passwords mismatch" - } else null - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email - ) - ) - - AnimatedTextField( - label = stringResource(R.string.PasswordHint), - value = TextFieldValue.fromMutableState( - state = viewModel.passwordHint, - valueLabel = TextFieldValue.ValueLabel( - type = TextFieldValue.ValueLabel.Type.INFO, - text = context.getString(R.string.PasswordHintLabel) - ) - ), - leading = { IconBox(Icons.Default.QuestionMark) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email - ) - ) - - ChoiceServer(viewModel.server) - } - } - - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - LoadingButton( - modifier = Modifier - .padding(vertical = 12.dp) - .height(50.dp) - .fillMaxWidth(0.85f), - onClick = { viewModel.register(context, navController) }, - enabled = viewModel.canLogin - ) { - Text( - text = stringResource(R.string.Signup), - style = MaterialTheme.typography.titleMedium - ) - } - } - } - } -} - -@Preview -@Composable -fun SignupPreview() { - MaterialTheme { - SignupScreen( - navController = rememberNavController(), - viewModel = SignupViewModel(LocalContext.current) - ) - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupViewModel.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupViewModel.kt deleted file mode 100644 index bac37a7b..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/SignupViewModel.kt +++ /dev/null @@ -1,54 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import android.content.Context -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import androidx.navigation.NavController -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.android.utils.showToast -import dev.medzik.librepass.android.common.haveNetworkConnection -import dev.medzik.librepass.android.ui.R -import dev.medzik.librepass.client.Server -import dev.medzik.librepass.client.api.AuthClient -import javax.inject.Inject - -@HiltViewModel -class SignupViewModel @Inject constructor( - @ApplicationContext - private val context: Context -) : ViewModel() { - val email = mutableStateOf("") - val password = mutableStateOf("") - val retypePassword = mutableStateOf("") - val retypePasswordIsValid = password.value.isNotEmpty() && - retypePassword.value == password.value - val passwordHint = mutableStateOf("") - val canLogin = email.value.isNotEmpty() && - password.value.isNotEmpty() && - retypePassword.value == password.value - - val server = mutableStateOf(Server.PRODUCTION) - - private val authClient = AuthClient(apiUrl = server.value) - - fun register(context: Context, navController: NavController) { - if (!context.haveNetworkConnection()) { - context.showToast(R.string.NoInternetConnection) - return - } - - try { - authClient.register( - email = email.value, - password = password.value - ) - - // TODO: disable back - navController.navigate(Login) - } catch (e: Exception) { - - } - } -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/TextFields.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/TextFields.kt deleted file mode 100644 index 2104320b..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/auth/TextFields.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Email -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import dev.medzik.android.compose.ui.IconBox -import dev.medzik.android.compose.ui.textfield.AnimatedTextField -import dev.medzik.android.compose.ui.textfield.PasswordAnimatedTextField -import dev.medzik.android.compose.ui.textfield.TextFieldValue -import dev.medzik.librepass.android.ui.R - -@Composable -fun EmailTextField(value: TextFieldValue) { - AnimatedTextField( - label = stringResource(R.string.Email), - value = value, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email - ), - leading = { IconBox(Icons.Default.Email) }, - singleLine = true - ) -} - -@Composable -fun PasswordTextField(value: TextFieldValue) { - PasswordAnimatedTextField( - label = stringResource(R.string.Password), - value = value, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email - ) - ) -} diff --git a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/vault/VaultHomeScreen.kt b/ui-logic/src/main/java/dev/medzik/librepass/android/ui/vault/VaultHomeScreen.kt deleted file mode 100644 index 13118514..00000000 --- a/ui-logic/src/main/java/dev/medzik/librepass/android/ui/vault/VaultHomeScreen.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.medzik.librepass.android.ui.vault - -import androidx.compose.runtime.Composable -import androidx.navigation.NavController -import dev.medzik.librepass.android.database.Credentials - -data class VaultHome( - val credentials: Credentials -) - -@Composable -fun VaultHomeScreen( - args: VaultHome, - navController: NavController -) { - -} diff --git a/ui-logic/src/main/res/values-pl/strings.xml b/ui-logic/src/main/res/values-pl/strings.xml deleted file mode 100644 index b0c2fef1..00000000 --- a/ui-logic/src/main/res/values-pl/strings.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - E-mail - Hasło - Zaloguj się - Brak połączenia z internetem - Oficjalny - Adres serwera - Wybierz serwer - Dodaj niestandardowy serwer - Nazwa - Wpisz swój adres e-mail - Podpowiedź do hasła została wysłana na twój adres e-maik - Dodaj - Brak połączenia z serwerem - Zapomniałeś hasła? - LibrePass chroni Twoje dane za pomocą zaawansowanego szyfrowania, uniemożliwiając odzyskanie hasła. Zamiast tego możesz poprosić o podpowiedź do hasła, którą ustawiłeś podczas rejestracji. - Uzyskaj podpowiedź do hasła - Utwórz konto - Witaj w LibrePass - Przejmij kontrolę nad swoimi hasłami dzięki LibrePass - Generator haseł - Generuj solidne, bezpieczne hasła bez wysiłku, aby zwiększyć swoje bezpieczeństwo - Synchronizacja pomiędzy urządzeniami - Hasła są płynnie synchronizowane na wszystkich urządzeniach - Podpowiedź dotycząca hasła - Zalecamy ustawienie podpowiedzi hasła, aby pomóc w przypadku zapomnienia hasła - Zapamiętaj swoje hasło, ponieważ nie będzie możliwości jego odzyskania - Wpisz hasło ponownie - \ No newline at end of file diff --git a/ui-logic/src/main/res/values/strings.xml b/ui-logic/src/main/res/values/strings.xml deleted file mode 100644 index c604bc7d..00000000 --- a/ui-logic/src/main/res/values/strings.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - E-mail - Password - Login - No internet connection - Official - Server address - Select server - Add custom server - Name - Enter your e-mail address - A password hint was sent to the e-mail address - Add - No connection with the server - Forgot password? - LibrePass utilizes robust encryption to safeguard your data, rendering password recovery impossible. Instead, you can request a password hint that you set during registration. - Get password hint - Sign up - Welcome in LibrePass - Take control of your passwords with LibrePass - Password Generator - Generate robust, secure passwords effortlessly to increase your security - Cross-device Sync - Passwords are seamlessly synchronized across all devices - Password hint - We recommend setting a password hint to help if you forget your password - Remember your password because there won\'t be any way to recover it - Re-type password - \ No newline at end of file diff --git a/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/HomePreviewScreenshot.kt b/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/HomePreviewScreenshot.kt deleted file mode 100644 index 0fe337c8..00000000 --- a/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/HomePreviewScreenshot.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.medzik.librepass.android.ui - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview - -class HomePreviewScreenshot { - @Preview - @Composable - fun HomeScreenLightPreview() { - MaterialTheme { - HomeScreenPreview() - } - } - - @Preview - @Composable - fun HomeScreenDarkPreview() { - MaterialTheme( - colorScheme = darkColorScheme() - ) { - HomeScreenPreview() - } - } -} diff --git a/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/LoginPreviewScreenshot.kt b/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/LoginPreviewScreenshot.kt deleted file mode 100644 index 5fd62cf7..00000000 --- a/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/LoginPreviewScreenshot.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview - -class LoginPreviewScreenshot { - @Preview - @Composable - fun LightPreview() { - MaterialTheme { - LoginScreenPreview() - } - } - - @Preview - @Composable - fun DarkPreview() { - MaterialTheme( - colorScheme = darkColorScheme() - ) { - LoginScreenPreview() - } - } -} diff --git a/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/SignupPreviewScreenshot.kt b/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/SignupPreviewScreenshot.kt deleted file mode 100644 index 356b6a9f..00000000 --- a/ui-logic/src/screenshotTest/java/dev/medzik/librepass/android/ui/auth/SignupPreviewScreenshot.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.medzik.librepass.android.ui.auth - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview - -class SignupPreviewScreenshot { - @Preview - @Composable - fun LightPreview() { - MaterialTheme { - SignupPreview() - } - } - - @Preview - @Composable - fun DarkPreview() { - MaterialTheme( - colorScheme = darkColorScheme() - ) { - SignupPreview() - } - } -}