From aa02e9f8cf233a01a00f3c5cbc91e899a9255b20 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 7 Jul 2023 20:48:36 +0200 Subject: [PATCH] feat: improve keystore UI and UX (#52) --- .../manager/domain/manager/KeystoreManager.kt | 21 +-- .../manager/ui/component/PasswordField.kt | 50 ++++++ .../settings/ImportExportSettingsScreen.kt | 151 ++++++++---------- .../ui/viewmodel/ImportExportViewModel.kt | 53 +++++- .../revanced/manager/util/signing/Signer.kt | 26 ++- app/src/main/res/values/strings.xml | 11 +- 6 files changed, 207 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt diff --git a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt index 8c0a630b..2e588eab 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt @@ -8,26 +8,24 @@ import java.io.File import java.io.InputStream import java.io.OutputStream import java.nio.file.Files +import java.nio.file.Path import java.nio.file.StandardCopyOption import kotlin.io.path.exists class KeystoreManager(app: Application, private val prefs: PreferencesManager) { companion object { /** - * Default common name and password for the keystore. + * Default alias and password for the keystore. */ const val DEFAULT = "ReVanced" - - /** - * The default password used by the Flutter version. - */ - const val FLUTTER_MANAGER_PASSWORD = "s3cur3p@ssw0rd" } - private val keystorePath = app.getDir("signing", Context.MODE_PRIVATE).resolve("manager.keystore").toPath() + private val keystorePath = + app.getDir("signing", Context.MODE_PRIVATE).resolve("manager.keystore").toPath() + private fun options( cn: String = prefs.keystoreCommonName!!, - pass: String = prefs.keystorePass!! + pass: String = prefs.keystorePass!!, ) = SigningOptions(cn, pass, keystorePath) private fun updatePrefs(cn: String, pass: String) { @@ -47,11 +45,14 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) { updatePrefs(DEFAULT, DEFAULT) } - fun import(cn: String, pass: String, keystore: InputStream) { - // TODO: check if the user actually provided the correct password + fun import(cn: String, pass: String, keystore: Path): Boolean { + if (!Signer(SigningOptions(cn, pass, keystore)).canUnlock()) { + return false + } Files.copy(keystore, keystorePath, StandardCopyOption.REPLACE_EXISTING) updatePrefs(cn, pass) + return true } fun export(target: OutputStream) { diff --git a/app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt b/app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt new file mode 100644 index 00000000..ee64c05b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt @@ -0,0 +1,50 @@ +package app.revanced.manager.ui.component + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import app.revanced.manager.R + +@Composable +fun PasswordField(modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null) { + var visible by rememberSaveable { + mutableStateOf(false) + } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + label = label, + modifier = modifier, + trailingIcon = { + IconButton(onClick = { + visible = !visible + }) { + val (icon, description) = remember(visible) { + if (visible) Icons.Outlined.VisibilityOff to R.string.hide_password_field else Icons.Outlined.Visibility to R.string.show_password_field + } + Icon(icon, stringResource(description)) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation() + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index e9656a8a..e6031530 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -1,35 +1,38 @@ package app.revanced.manager.ui.screen.settings -import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Key import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable 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.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.ui.viewmodel.ImportExportViewModel -import app.revanced.manager.domain.manager.KeystoreManager.Companion.DEFAULT -import app.revanced.manager.domain.manager.KeystoreManager.Companion.FLUTTER_MANAGER_PASSWORD -import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.ContentSelector import app.revanced.manager.ui.component.GroupHeader +import app.revanced.manager.ui.component.PasswordField import app.revanced.manager.ui.component.sources.SourceSelector +import app.revanced.manager.util.toast import org.koin.androidx.compose.getViewModel -import org.koin.compose.rememberKoinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,8 +40,14 @@ fun ImportExportSettingsScreen( onBackClick: () -> Unit, vm: ImportExportViewModel = getViewModel() ) { - var showImportKeystoreDialog by rememberSaveable { mutableStateOf(false) } - var showExportKeystoreDialog by rememberSaveable { mutableStateOf(false) } + val importKeystoreLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + it?.let { uri -> vm.startKeystoreImport(uri) } + } + val exportKeystoreLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { + it?.let(vm::exportKeystore) + } vm.selectionAction?.let { action -> val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -62,16 +71,10 @@ fun ImportExportSettingsScreen( } } - if (showImportKeystoreDialog) { - ImportKeystoreDialog( - onDismissRequest = { showImportKeystoreDialog = false }, - onImport = vm::importKeystore - ) - } - if (showExportKeystoreDialog) { - ExportKeystoreDialog( - onDismissRequest = { showExportKeystoreDialog = false }, - onExport = vm::exportKeystore + if (vm.showCredentialsDialog) { + KeystoreCredentialsDialog( + onDismissRequest = vm::cancelKeystoreImport, + tryImport = vm::tryKeystoreImport ) } @@ -92,14 +95,14 @@ fun ImportExportSettingsScreen( GroupHeader(stringResource(R.string.signing)) GroupItem( onClick = { - showImportKeystoreDialog = true + importKeystoreLauncher.launch("*/*") }, headline = R.string.import_keystore, description = R.string.import_keystore_descripion ) GroupItem( onClick = { - showExportKeystoreDialog = true + exportKeystoreLauncher.launch("Manager.keystore") }, headline = R.string.export_keystore, description = R.string.export_keystore_description @@ -139,90 +142,64 @@ private fun GroupItem(onClick: () -> Unit, @StringRes headline: Int, @StringRes ) @Composable -fun ExportKeystoreDialog( +fun KeystoreCredentialsDialog( onDismissRequest: () -> Unit, - onExport: (Uri) -> Unit + tryImport: (String, String) -> Boolean ) { - val activityLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> - uri?.let { - onExport(it) - onDismissRequest() - } - } - val prefs: PreferencesManager = rememberKoinInject() + val context = LocalContext.current + var cn by rememberSaveable { mutableStateOf("") } + var pass by rememberSaveable { mutableStateOf("") } AlertDialog( onDismissRequest = onDismissRequest, confirmButton = { - Button( - onClick = { activityLauncher.launch("Manager.keystore") } - ) { - Text(stringResource(R.string.select_file)) - } - }, - title = { Text(stringResource(R.string.export_keystore)) }, - text = { - Column { - Text("Current common name: ${prefs.keystoreCommonName}") - Text("Current password: ${prefs.keystorePass}") - } - } - ) -} - -@Composable -fun ImportKeystoreDialog( - onDismissRequest: () -> Unit, onImport: (Uri, String, String) -> Unit -) { - var cn by rememberSaveable { mutableStateOf(DEFAULT) } - var pass by rememberSaveable { mutableStateOf(DEFAULT) } - - AlertDialog( - onDismissRequest = onDismissRequest, - confirmButton = { - ContentSelector( - mime = "*/*", - onSelect = { - onImport(it, cn, pass) - onDismissRequest() + TextButton( + onClick = { + if (!tryImport( + cn, + pass + ) + ) context.toast(context.getString(R.string.import_keystore_wrong_credentials)) } ) { - Text(stringResource(R.string.select_file)) + Text(stringResource(R.string.import_keystore_dialog_button)) } }, - title = { Text(stringResource(R.string.import_keystore)) }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + }, + icon = { + Icon(Icons.Outlined.Key, null) + }, + title = { + Text( + text = stringResource(R.string.import_keystore_dialog_title), + style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center), + color = MaterialTheme.colorScheme.onSurface, + ) + }, text = { - Column { - TextField( + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.import_keystore_dialog_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedTextField( value = cn, onValueChange = { cn = it }, - label = { Text("Common Name") } + label = { Text(stringResource(R.string.import_keystore_dialog_alias_field)) } ) - TextField( + PasswordField( value = pass, onValueChange = { pass = it }, - label = { Text("Password") } + label = { Text(stringResource(R.string.import_keystore_dialog_password_field)) } ) - - Text("Credential presets") - - Button( - onClick = { - cn = DEFAULT - pass = DEFAULT - } - ) { - Text(stringResource(R.string.import_keystore_preset_default)) - } - Button( - onClick = { - cn = DEFAULT - pass = FLUTTER_MANAGER_PASSWORD - } - ) { - Text(stringResource(R.string.import_keystore_preset_flutter)) - } } } ) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt index 4304071f..8127e885 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.net.Uri import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -25,6 +26,11 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.deleteExisting @OptIn(ExperimentalSerializationApi::class) class ImportExportViewModel( @@ -39,9 +45,48 @@ class ImportExportViewModel( private set var selectionAction by mutableStateOf(null) private set + private var keystoreImportPath by mutableStateOf(null) + val showCredentialsDialog by derivedStateOf { keystoreImportPath != null } - fun importKeystore(content: Uri, cn: String, pass: String) = - keystoreManager.import(cn, pass, contentResolver.openInputStream(content)!!) + fun startKeystoreImport(content: Uri) { + val path = File.createTempFile("signing", "ks", app.cacheDir).toPath() + Files.copy( + contentResolver.openInputStream(content)!!, + path, + StandardCopyOption.REPLACE_EXISTING + ) + + knownPasswords.forEach { + if (tryKeystoreImport(KeystoreManager.DEFAULT, it, path)) { + return + } + } + + keystoreImportPath = path + } + + fun cancelKeystoreImport() { + keystoreImportPath?.deleteExisting() + keystoreImportPath = null + } + + fun tryKeystoreImport(cn: String, pass: String) = + tryKeystoreImport(cn, pass, keystoreImportPath!!) + + private fun tryKeystoreImport(cn: String, pass: String, path: Path): Boolean { + if (keystoreManager.import(cn, pass, path)) { + cancelKeystoreImport() + return true + } + + return false + } + + override fun onCleared() { + super.onCleared() + + cancelKeystoreImport() + } fun exportKeystore(target: Uri) = keystoreManager.export(contentResolver.openOutputStream(target)!!) @@ -120,4 +165,8 @@ class ImportExportViewModel( } } } + + private companion object { + val knownPasswords = setOf("ReVanced", "s3cur3p@ssw0rd") + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/signing/Signer.kt b/app/src/main/java/app/revanced/manager/util/signing/Signer.kt index 9c6e0da4..de875e91 100644 --- a/app/src/main/java/app/revanced/manager/util/signing/Signer.kt +++ b/app/src/main/java/app/revanced/manager/util/signing/Signer.kt @@ -11,6 +11,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.ContentSigner import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import java.io.File +import java.io.InputStream import java.math.BigInteger import java.nio.file.Path import java.security.* @@ -55,16 +56,33 @@ class Signer( return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private } - fun signApk(input: File, output: File) { - Security.addProvider(BouncyCastleProvider()) - + private fun loadKeystore(): KeyStore { val ks = signingOptions.keyStoreFilePath if (!ks.exists()) newKeystore(ks) else { Log.i(tag, "Found existing keystore: ${ks.name}") } + Security.addProvider(BouncyCastleProvider()) val keyStore = KeyStore.getInstance("BKS", "BC") - ks.inputStream().use { stream -> keyStore.load(stream, null) } + ks.inputStream().use { keyStore.load(it, null) } + return keyStore + } + + fun canUnlock(): Boolean { + val keyStore = loadKeystore() + val alias = keyStore.aliases().nextElement() + + try { + keyStore.getKey(alias, passwordCharArray) + } catch (_: UnrecoverableKeyException) { + return false + } + + return true + } + + fun signApk(input: File, output: File) { + val keyStore = loadKeystore() val alias = keyStore.aliases().nextElement() val config = ApkSigner.SignerConfig.Builder( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5ae69b9..54dadd4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,8 +36,12 @@ Allow patching incompatible patches with experimental versions, something may break Import keystore Import a custom keystore - Default - ReVanced Manager (Flutter) + Enter keystore credentials + You\'ll need enter the keystore’s credentials to import it. + Username (Alias) + Password + Import + Wrong keystore credentials Export keystore Export the current keystore Regenerate keystore @@ -97,6 +101,9 @@ Select file + Show password + Hide password + Installer Install App installed