mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2024-11-10 01:01:56 +01:00
feat: improve keystore UI and UX (#52)
This commit is contained in:
parent
37e177b56e
commit
aa02e9f8cf
6 changed files with 207 additions and 105 deletions
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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 keystore’s 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>
|
||||
|
|
Loading…
Reference in a new issue