diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3151dd..a6fea23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,10 @@ dependencies { // ReVanced implementation("app.revanced:revanced-patcher:7.1.0") + // Signing + implementation("com.android.tools.build:apksig:8.1.0-beta02") + implementation("org.bouncycastle:bcpkix-jdk15on:1.70") + // Koin val koinVersion = "3.4.0" implementation("io.insert-koin:koin-android:$koinVersion") diff --git a/app/src/main/java/app/revanced/manager/compose/MainActivity.kt b/app/src/main/java/app/revanced/manager/compose/MainActivity.kt index 5aecb60..32f7bb9 100644 --- a/app/src/main/java/app/revanced/manager/compose/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/compose/MainActivity.kt @@ -75,12 +75,20 @@ class MainActivity : ComponentActivity() { vm = getViewModel { parametersOf(destination.input) } ) - is Destination.Installer -> InstallerScreen(getViewModel { - parametersOf( - destination.input, - destination.selectedPatches - ) - }) + is Destination.Installer -> InstallerScreen( + onBackClick = { + with(navController) { + popAll() + navigate(Destination.Dashboard) + } + }, + vm = getViewModel { + parametersOf( + destination.input, + destination.selectedPatches + ) + } + ) } } } diff --git a/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt index 353e6a2..db041b7 100644 --- a/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt @@ -2,6 +2,7 @@ package app.revanced.manager.compose.di import app.revanced.manager.compose.network.service.HttpService import app.revanced.manager.compose.network.service.ReVancedService +import app.revanced.manager.compose.patcher.SignerService import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -16,4 +17,5 @@ val serviceModule = module { single { provideReVancedService(get()) } singleOf(::HttpService) + singleOf(::SignerService) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt index 474e3ea..a0ad114 100644 --- a/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt @@ -21,7 +21,8 @@ val viewModelModule = module { InstallerScreenViewModel( input = it.get(), selectedPatches = it.get(), - app = get() + app = get(), + signerService = get(), ) } } diff --git a/app/src/main/java/app/revanced/manager/compose/patcher/SignerService.kt b/app/src/main/java/app/revanced/manager/compose/patcher/SignerService.kt new file mode 100644 index 0000000..2a069cb --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/patcher/SignerService.kt @@ -0,0 +1,11 @@ +package app.revanced.manager.compose.patcher + +import android.app.Application +import app.revanced.manager.compose.util.signing.Signer +import app.revanced.manager.compose.util.signing.SigningOptions + +class SignerService(app: Application) { + private val options = SigningOptions("ReVanced", "ReVanced", app.dataDir.resolve("manager.keystore").path) + + fun createSigner() = Signer(options) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherProgressManager.kt b/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherProgressManager.kt index 9b0bef1..37721b8 100644 --- a/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherProgressManager.kt +++ b/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherProgressManager.kt @@ -7,7 +7,6 @@ import androidx.work.workDataOf import app.revanced.manager.compose.R import app.revanced.manager.compose.patcher.Session import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/app/src/main/java/app/revanced/manager/compose/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/compose/ui/screen/AppSelectorScreen.kt index 5047bcc..34c7b20 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/screen/AppSelectorScreen.kt @@ -26,6 +26,7 @@ import app.revanced.manager.compose.ui.component.AppIcon import app.revanced.manager.compose.ui.component.AppTopBar import app.revanced.manager.compose.ui.component.LoadingIndicator import app.revanced.manager.compose.ui.viewmodel.AppSelectorViewModel +import app.revanced.manager.compose.util.APK_MIMETYPE import app.revanced.manager.compose.util.PM import app.revanced.manager.compose.util.PackageInfo import org.koin.androidx.compose.getViewModel @@ -125,7 +126,7 @@ fun AppSelectorScreen( ListItem( modifier = Modifier.clickable { - pickApkLauncher.launch("*/*") + pickApkLauncher.launch(APK_MIMETYPE) }, leadingContent = { Box(Modifier.size(36.dp), Alignment.Center) { diff --git a/app/src/main/java/app/revanced/manager/compose/ui/screen/InstallerScreen.kt b/app/src/main/java/app/revanced/manager/compose/ui/screen/InstallerScreen.kt index ed282f8..b316f6a 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/screen/InstallerScreen.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/screen/InstallerScreen.kt @@ -1,8 +1,11 @@ package app.revanced.manager.compose.ui.screen +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -13,17 +16,21 @@ import app.revanced.manager.compose.R import app.revanced.manager.compose.ui.component.AppScaffold import app.revanced.manager.compose.ui.component.AppTopBar import app.revanced.manager.compose.ui.viewmodel.InstallerScreenViewModel +import app.revanced.manager.compose.util.APK_MIMETYPE @OptIn(ExperimentalMaterial3Api::class) @Composable fun InstallerScreen( + onBackClick: () -> Unit, vm: InstallerScreenViewModel ) { + val exportApkLauncher = rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export) + AppScaffold( topBar = { AppTopBar( title = stringResource(R.string.installer), - onBackClick = { }, + onBackClick = onBackClick, ) } ) { paddingValues -> @@ -47,6 +54,20 @@ fun InstallerScreen( } } } + + Button( + onClick = vm::installApk, + enabled = vm.canInstall + ) { + Text(stringResource(R.string.install_app)) + } + + Button( + onClick = { exportApkLauncher.launch("${vm.packageName}.apk") }, + enabled = vm.canInstall + ) { + Text(stringResource(R.string.export_app)) + } } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt index 586d85d..d6c8b53 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt @@ -6,12 +6,16 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageInstaller +import android.net.Uri +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.work.* +import app.revanced.manager.compose.R +import app.revanced.manager.compose.patcher.SignerService import app.revanced.manager.compose.patcher.worker.PatcherProgressManager import app.revanced.manager.compose.patcher.worker.PatcherWorker import app.revanced.manager.compose.patcher.worker.StepGroup @@ -19,26 +23,38 @@ import app.revanced.manager.compose.service.InstallService import app.revanced.manager.compose.service.UninstallService import app.revanced.manager.compose.util.PM import app.revanced.manager.compose.util.PackageInfo +import app.revanced.manager.compose.util.toast import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File +import java.nio.file.Files class InstallerScreenViewModel( input: PackageInfo, selectedPatches: List, - private val app: Application + private val app: Application, + private val signerService: SignerService ) : ViewModel() { var stepGroups by mutableStateOf>(PatcherProgressManager.generateGroupsList(app, selectedPatches)) private set + val packageName = input.packageName + private val workManager = WorkManager.getInstance(app) - // TODO: handle app installation as a step. + // TODO: get rid of these and use stepGroups instead. var installStatus by mutableStateOf(null) var pmStatus by mutableStateOf(-999) var extra by mutableStateOf("") private val outputFile = File(app.cacheDir, "output.apk") + private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() } + private var hasSigned = false + private var patcherStatus by mutableStateOf(null) + private var isInstalling by mutableStateOf(false) + + val canInstall by derivedStateOf { patcherStatus == true && !isInstalling } + private val patcherWorker = OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker @@ -51,7 +67,7 @@ class InstallerScreenViewModel( outputFile.path, selectedPatches, input.packageName, - input.packageName, + input.version, ) ) ) @@ -62,7 +78,10 @@ class InstallerScreenViewModel( private val observer = Observer { workInfo: WorkInfo -> // observer for observing patch status when (workInfo.state) { WorkInfo.State.RUNNING -> workInfo.progress - WorkInfo.State.FAILED, WorkInfo.State.SUCCEEDED -> workInfo.outputData + WorkInfo.State.FAILED, WorkInfo.State.SUCCEEDED -> workInfo.outputData.also { + patcherStatus = workInfo.state == WorkInfo.State.SUCCEEDED + } + else -> null }?.let { PatcherProgressManager.groupsFromWorkData(it) }?.let { stepGroups = it } } @@ -91,8 +110,35 @@ class InstallerScreenViewModel( }) } - fun installApk(apk: List) { - PM.installApp(apk, app) + private fun signApk(): Boolean { + if (!hasSigned) { + try { + signerService.createSigner().signApk(outputFile, signedFile) + } catch (e: Throwable) { + e.printStackTrace() + app.toast(app.getString(R.string.sign_fail, e::class.simpleName)) + return false + } + } + + return true + } + + fun export(uri: Uri?) = uri?.let { + if (signApk()) { + Files.copy(signedFile.toPath(), app.contentResolver.openOutputStream(it)) + app.toast(app.getString(R.string.export_app_success)) + } + } + + fun installApk() { + isInstalling = true + try { + if (!signApk()) return + PM.installApp(listOf(signedFile), app) + } finally { + isInstalling = false + } } fun postInstallStatus() { @@ -105,5 +151,8 @@ class InstallerScreenViewModel( app.unregisterReceiver(installBroadcastReceiver) workManager.cancelWorkById(patcherWorker.id) // logs.clear() + + outputFile.delete() + signedFile.delete() } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/util/Util.kt b/app/src/main/java/app/revanced/manager/compose/util/Util.kt index a20d075..4385c67 100644 --- a/app/src/main/java/app/revanced/manager/compose/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/compose/util/Util.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.Drawable import android.widget.Toast import androidx.core.net.toUri +const val APK_MIMETYPE = "application/vnd.android.package-archive" fun Context.openUrl(url: String) { startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply { diff --git a/app/src/main/java/app/revanced/manager/compose/util/signing/Signer.kt b/app/src/main/java/app/revanced/manager/compose/util/signing/Signer.kt new file mode 100644 index 0000000..60c1a02 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/util/signing/Signer.kt @@ -0,0 +1,77 @@ +package app.revanced.manager.compose.util.signing + +import android.util.Log +import com.android.apksig.ApkSigner +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.cert.X509v3CertificateBuilder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.math.BigInteger +import java.security.* +import java.security.cert.X509Certificate +import java.util.* + +class Signer( + private val signingOptions: SigningOptions +) { + private val passwordCharArray = signingOptions.password.toCharArray() + private fun newKeystore(out: File) { + val (publicKey, privateKey) = createKey() + val privateKS = KeyStore.getInstance("BKS", "BC") + privateKS.load(null, passwordCharArray) + privateKS.setKeyEntry("alias", privateKey, passwordCharArray, arrayOf(publicKey)) + privateKS.store(FileOutputStream(out), passwordCharArray) + } + + private fun createKey(): Pair { + val gen = KeyPairGenerator.getInstance("RSA") + gen.initialize(4096) + val pair = gen.generateKeyPair() + var serialNumber: BigInteger + do serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) while (serialNumber < BigInteger.ZERO) + val x500Name = X500Name("CN=${signingOptions.cn}") + val builder = X509v3CertificateBuilder( + x500Name, + serialNumber, + Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L), + Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L), + Locale.ENGLISH, + x500Name, + SubjectPublicKeyInfo.getInstance(pair.public.encoded) + ) + val signer: ContentSigner = JcaContentSignerBuilder("SHA256withRSA").build(pair.private) + return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private + } + + fun signApk(input: File, output: File) { + Security.addProvider(BouncyCastleProvider()) + + val ks = File(signingOptions.keyStoreFilePath) + if (!ks.exists()) newKeystore(ks) else { + Log.i("revanced-manager", "Found existing keystore: ${ks.name}") + } + + val keyStore = KeyStore.getInstance("BKS", "BC") + FileInputStream(ks).use { fis -> keyStore.load(fis, null) } + val alias = keyStore.aliases().nextElement() + + val config = ApkSigner.SignerConfig.Builder( + signingOptions.cn, + keyStore.getKey(alias, passwordCharArray) as PrivateKey, + listOf(keyStore.getCertificate(alias) as X509Certificate) + ).build() + + val signer = ApkSigner.Builder(listOf(config)) + signer.setCreatedBy(signingOptions.cn) + signer.setInputApk(input) + signer.setOutputApk(output) + + signer.build().sign() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/util/signing/SigningOptions.kt b/app/src/main/java/app/revanced/manager/compose/util/signing/SigningOptions.kt new file mode 100644 index 0000000..df88b54 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/util/signing/SigningOptions.kt @@ -0,0 +1,7 @@ +package app.revanced.manager.compose.util.signing + +data class SigningOptions( + val cn: String, + val password: String, + val keyStoreFilePath: String +) \ 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 b22bb1a..758984d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,10 @@ Some of the patches do not support this app version (%s). The patches only support the following versions: %s. Installer + Install + Export + Apk exported + Failed to sign Apk: %s Preparation Unpack Apk