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.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import kotlin.io.path.exists import kotlin.io.path.exists
class KeystoreManager(app: Application, private val prefs: PreferencesManager) { class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
companion object { companion object {
/** /**
* Default common name and password for the keystore. * Default alias and password for the keystore.
*/ */
const val DEFAULT = "ReVanced" 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( private fun options(
cn: String = prefs.keystoreCommonName!!, cn: String = prefs.keystoreCommonName!!,
pass: String = prefs.keystorePass!! pass: String = prefs.keystorePass!!,
) = SigningOptions(cn, pass, keystorePath) ) = SigningOptions(cn, pass, keystorePath)
private fun updatePrefs(cn: String, pass: String) { private fun updatePrefs(cn: String, pass: String) {
@ -47,11 +45,14 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
updatePrefs(DEFAULT, DEFAULT) updatePrefs(DEFAULT, DEFAULT)
} }
fun import(cn: String, pass: String, keystore: InputStream) { fun import(cn: String, pass: String, keystore: Path): Boolean {
// TODO: check if the user actually provided the correct password if (!Signer(SigningOptions(cn, pass, keystore)).canUnlock()) {
return false
}
Files.copy(keystore, keystorePath, StandardCopyOption.REPLACE_EXISTING) Files.copy(keystore, keystorePath, StandardCopyOption.REPLACE_EXISTING)
updatePrefs(cn, pass) updatePrefs(cn, pass)
return true
} }
fun export(target: OutputStream) { 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 package app.revanced.manager.ui.screen.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.viewmodel.ImportExportViewModel 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.AppTopBar
import app.revanced.manager.ui.component.ContentSelector
import app.revanced.manager.ui.component.GroupHeader 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.ui.component.sources.SourceSelector
import app.revanced.manager.util.toast
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.getViewModel
import org.koin.compose.rememberKoinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -37,8 +40,14 @@ fun ImportExportSettingsScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: ImportExportViewModel = getViewModel() vm: ImportExportViewModel = getViewModel()
) { ) {
var showImportKeystoreDialog by rememberSaveable { mutableStateOf(false) } val importKeystoreLauncher =
var showExportKeystoreDialog by rememberSaveable { mutableStateOf(false) } rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) {
it?.let { uri -> vm.startKeystoreImport(uri) }
}
val exportKeystoreLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) {
it?.let(vm::exportKeystore)
}
vm.selectionAction?.let { action -> vm.selectionAction?.let { action ->
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
@ -62,16 +71,10 @@ fun ImportExportSettingsScreen(
} }
} }
if (showImportKeystoreDialog) { if (vm.showCredentialsDialog) {
ImportKeystoreDialog( KeystoreCredentialsDialog(
onDismissRequest = { showImportKeystoreDialog = false }, onDismissRequest = vm::cancelKeystoreImport,
onImport = vm::importKeystore tryImport = vm::tryKeystoreImport
)
}
if (showExportKeystoreDialog) {
ExportKeystoreDialog(
onDismissRequest = { showExportKeystoreDialog = false },
onExport = vm::exportKeystore
) )
} }
@ -92,14 +95,14 @@ fun ImportExportSettingsScreen(
GroupHeader(stringResource(R.string.signing)) GroupHeader(stringResource(R.string.signing))
GroupItem( GroupItem(
onClick = { onClick = {
showImportKeystoreDialog = true importKeystoreLauncher.launch("*/*")
}, },
headline = R.string.import_keystore, headline = R.string.import_keystore,
description = R.string.import_keystore_descripion description = R.string.import_keystore_descripion
) )
GroupItem( GroupItem(
onClick = { onClick = {
showExportKeystoreDialog = true exportKeystoreLauncher.launch("Manager.keystore")
}, },
headline = R.string.export_keystore, headline = R.string.export_keystore,
description = R.string.export_keystore_description description = R.string.export_keystore_description
@ -139,90 +142,64 @@ private fun GroupItem(onClick: () -> Unit, @StringRes headline: Int, @StringRes
) )
@Composable @Composable
fun ExportKeystoreDialog( fun KeystoreCredentialsDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onExport: (Uri) -> Unit tryImport: (String, String) -> Boolean
) { ) {
val activityLauncher = val context = LocalContext.current
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> var cn by rememberSaveable { mutableStateOf("") }
uri?.let { var pass by rememberSaveable { mutableStateOf("") }
onExport(it)
onDismissRequest()
}
}
val prefs: PreferencesManager = rememberKoinInject()
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
confirmButton = { confirmButton = {
Button( TextButton(
onClick = { activityLauncher.launch("Manager.keystore") } onClick = {
) { if (!tryImport(
Text(stringResource(R.string.select_file)) cn,
} pass
},
title = { Text(stringResource(R.string.export_keystore)) },
text = {
Column {
Text("Current common name: ${prefs.keystoreCommonName}")
Text("Current password: ${prefs.keystorePass}")
}
}
) )
} ) context.toast(context.getString(R.string.import_keystore_wrong_credentials))
@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()
} }
) { ) {
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 = { text = {
Column { Column(
TextField( 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, value = cn,
onValueChange = { cn = it }, onValueChange = { cn = it },
label = { Text("Common Name") } label = { Text(stringResource(R.string.import_keystore_dialog_alias_field)) }
) )
TextField( PasswordField(
value = pass, value = pass,
onValueChange = { pass = it }, 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 android.net.Uri
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -25,6 +26,11 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream 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) @OptIn(ExperimentalSerializationApi::class)
class ImportExportViewModel( class ImportExportViewModel(
@ -39,9 +45,48 @@ class ImportExportViewModel(
private set private set
var selectionAction by mutableStateOf<SelectionAction?>(null) var selectionAction by mutableStateOf<SelectionAction?>(null)
private set private set
private var keystoreImportPath by mutableStateOf<Path?>(null)
val showCredentialsDialog by derivedStateOf { keystoreImportPath != null }
fun importKeystore(content: Uri, cn: String, pass: String) = fun startKeystoreImport(content: Uri) {
keystoreManager.import(cn, pass, contentResolver.openInputStream(content)!!) 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) = fun exportKeystore(target: Uri) =
keystoreManager.export(contentResolver.openOutputStream(target)!!) 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.ContentSigner
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.io.File import java.io.File
import java.io.InputStream
import java.math.BigInteger import java.math.BigInteger
import java.nio.file.Path import java.nio.file.Path
import java.security.* import java.security.*
@ -55,16 +56,33 @@ class Signer(
return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private
} }
fun signApk(input: File, output: File) { private fun loadKeystore(): KeyStore {
Security.addProvider(BouncyCastleProvider())
val ks = signingOptions.keyStoreFilePath val ks = signingOptions.keyStoreFilePath
if (!ks.exists()) newKeystore(ks) else { if (!ks.exists()) newKeystore(ks) else {
Log.i(tag, "Found existing keystore: ${ks.name}") Log.i(tag, "Found existing keystore: ${ks.name}")
} }
Security.addProvider(BouncyCastleProvider())
val keyStore = KeyStore.getInstance("BKS", "BC") 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 alias = keyStore.aliases().nextElement()
val config = ApkSigner.SignerConfig.Builder( 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="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">Import keystore</string>
<string name="import_keystore_descripion">Import a custom keystore</string> <string name="import_keystore_descripion">Import a custom keystore</string>
<string name="import_keystore_preset_default">Default</string> <string name="import_keystore_dialog_title">Enter keystore credentials</string>
<string name="import_keystore_preset_flutter">ReVanced Manager (Flutter)</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">Export keystore</string>
<string name="export_keystore_description">Export the current keystore</string> <string name="export_keystore_description">Export the current keystore</string>
<string name="regenerate_keystore">Regenerate keystore</string> <string name="regenerate_keystore">Regenerate keystore</string>
@ -97,6 +101,9 @@
<string name="select_file">Select file</string> <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="installer">Installer</string>
<string name="install_app">Install</string> <string name="install_app">Install</string>
<string name="install_app_success">App installed</string> <string name="install_app_success">App installed</string>