feat: improve keystore UI and UX (#52)

This commit is contained in:
Ax333l 2023-07-07 20:48:36 +02:00 committed by GitHub
parent 37e177b56e
commit aa02e9f8cf
6 changed files with 207 additions and 105 deletions

View file

@ -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) {

View file

@ -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()
)
}

View file

@ -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))
}
}
}
)

View file

@ -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<SelectionAction?>(null)
private set
private var keystoreImportPath by mutableStateOf<Path?>(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")
}
}

View file

@ -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(

View file

@ -36,8 +36,12 @@
<string name="experimental_patches_description">Allow patching incompatible patches with experimental versions, something may break</string>
<string name="import_keystore">Import keystore</string>
<string name="import_keystore_descripion">Import a custom keystore</string>
<string name="import_keystore_preset_default">Default</string>
<string name="import_keystore_preset_flutter">ReVanced Manager (Flutter)</string>
<string name="import_keystore_dialog_title">Enter keystore credentials</string>
<string name="import_keystore_dialog_description">You\'ll need enter the keystores credentials to import it.</string>
<string name="import_keystore_dialog_alias_field">Username (Alias)</string>
<string name="import_keystore_dialog_password_field">Password</string>
<string name="import_keystore_dialog_button">Import</string>
<string name="import_keystore_wrong_credentials">Wrong keystore credentials</string>
<string name="export_keystore">Export keystore</string>
<string name="export_keystore_description">Export the current keystore</string>
<string name="regenerate_keystore">Regenerate keystore</string>
@ -97,6 +101,9 @@
<string name="select_file">Select file</string>
<string name="show_password_field">Show password</string>
<string name="hide_password_field">Hide password</string>
<string name="installer">Installer</string>
<string name="install_app">Install</string>
<string name="install_app_success">App installed</string>