feat: integrate revanced patcher (#22)

This commit is contained in:
Ax333l 2023-05-19 20:49:32 +02:00 committed by GitHub
parent f1656c6d1e
commit 40487923f9
36 changed files with 1457 additions and 314 deletions

View File

@ -30,6 +30,13 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
packagingOptions {
resources {
excludes += "/prebuilt/**"
}
}
kotlinOptions {
jvmTarget = "11"
}
@ -43,10 +50,12 @@ dependencies {
// AndroidX Core
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-compose:1.7.1")
implementation("androidx.paging:paging-common-ktx:3.1.1")
implementation("androidx.work:work-runtime-ktx:2.8.1")
// Compose
implementation(platform("androidx.compose:compose-bom:2023.05.01"))
@ -70,11 +79,13 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
// ReVanced
implementation("app.revanced:revanced-patcher:7.0.0")
implementation("app.revanced:revanced-patcher:7.1.0")
// Koin
implementation("io.insert-koin:koin-android:3.4.0")
val koinVersion = "3.4.0"
implementation("io.insert-koin:koin-android:$koinVersion")
implementation("io.insert-koin:koin-androidx-compose:3.4.4")
implementation("io.insert-koin:koin-androidx-workmanager:$koinVersion")
// Compose Navigation
implementation("dev.olshevski.navigation:reimagined:1.4.0")

View File

@ -19,6 +19,8 @@
android:name=".ManagerApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:extractNativeLibs="true"
android:largeHeap="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -40,5 +42,16 @@
<service android:name=".service.InstallService" />
<service android:name=".service.UninstallService" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@ -12,6 +12,7 @@ import app.revanced.manager.compose.ui.screen.AppSelectorScreen
import app.revanced.manager.compose.ui.screen.DashboardScreen
import app.revanced.manager.compose.ui.screen.PatchesSelectorScreen
import app.revanced.manager.compose.ui.screen.SettingsScreen
import app.revanced.manager.compose.ui.screen.InstallerScreen
import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme
import app.revanced.manager.compose.ui.theme.Theme
import app.revanced.manager.compose.util.PM
@ -24,6 +25,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.getViewModel
import org.koin.core.parameter.parametersOf
class MainActivity : ComponentActivity() {
private val prefs: PreferencesManager by inject()
@ -53,7 +56,6 @@ class MainActivity : ComponentActivity() {
controller = navController
) { destination ->
when (destination) {
is Destination.Dashboard -> DashboardScreen(
onSettingsClick = { navController.navigate(Destination.Settings) },
onAppSelectorClick = { navController.navigate(Destination.AppSelector) }
@ -64,14 +66,29 @@ class MainActivity : ComponentActivity() {
)
is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.PatchesSelector) },
onAppClick = { navController.navigate(Destination.PatchesSelector(it)) },
onBackClick = { navController.pop() }
)
is Destination.PatchesSelector -> PatchesSelectorScreen(
onBackClick = { navController.pop() }
onBackClick = { navController.pop() },
startPatching = {
navController.navigate(
Destination.Installer(
destination.input,
it
)
)
},
vm = getViewModel { parametersOf(destination.input) }
)
is Destination.Installer -> InstallerScreen(getViewModel {
parametersOf(
destination.input,
destination.selectedPatches
)
})
}
}
}

View File

@ -3,20 +3,23 @@ package app.revanced.manager.compose
import android.app.Application
import app.revanced.manager.compose.di.*
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.core.context.startKoin
class ManagerApplication: Application() {
class ManagerApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@ManagerApplication)
workManagerFactory()
modules(
httpModule,
preferencesModule,
repositoryModule,
serviceModule,
viewModelModule
workerModule,
viewModelModule,
)
}
}

View File

@ -2,10 +2,12 @@ package app.revanced.manager.compose.di
import app.revanced.manager.compose.domain.repository.ReVancedRepositoryImpl
import app.revanced.manager.compose.network.api.ManagerAPI
import app.revanced.manager.compose.patcher.data.repository.*
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val repositoryModule = module {
singleOf(::ReVancedRepositoryImpl)
singleOf(::ManagerAPI)
singleOf(::PatchesRepository)
}

View File

@ -2,9 +2,23 @@ package app.revanced.manager.compose.di
import app.revanced.manager.compose.ui.viewmodel.*
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::PatchesSelectorViewModel)
viewModel {
PatchesSelectorViewModel(
packageInfo = it.get(),
patchesRepository = get()
)
}
viewModelOf(::SettingsViewModel)
}
viewModelOf(::AppSelectorViewModel)
viewModel {
InstallerScreenViewModel(
input = it.get(),
selectedPatches = it.get(),
app = get()
)
}
}

View File

@ -0,0 +1,9 @@
package app.revanced.manager.compose.di
import app.revanced.manager.compose.patcher.worker.PatcherWorker
import org.koin.androidx.workmanager.dsl.workerOf
import org.koin.dsl.module
val workerModule = module {
workerOf(::PatcherWorker)
}

View File

@ -34,28 +34,36 @@ class ManagerAPI(
downloadProgress = null
}
suspend fun downloadPatchBundle() {
suspend fun downloadPatchBundle(): File? {
try {
val downloadUrl = revancedRepository.findAsset(ghPatches, ".jar").downloadUrl
val patchesFile = app.filesDir.resolve("patch-bundles").also { it.mkdirs() }
.resolve("patchbundle.jar")
downloadAsset(downloadUrl, patchesFile)
return patchesFile
} catch (e: Exception) {
Log.e(tag, "Failed to download patch bundle", e)
app.toast("Failed to download patch bundle")
}
return null
}
suspend fun downloadIntegrations() {
suspend fun downloadIntegrations(): File? {
try {
val downloadUrl = revancedRepository.findAsset(ghIntegrations, ".apk").downloadUrl
val integrationsFile = app.filesDir.resolve("integrations").also { it.mkdirs() }
.resolve("integrations.apk")
downloadAsset(downloadUrl, integrationsFile)
return integrationsFile
} catch (e: Exception) {
Log.e(tag, "Failed to download integrations", e)
app.toast("Failed to download integrations")
}
return null
}
}

View File

@ -0,0 +1,38 @@
package app.revanced.manager.compose.patcher
import app.revanced.manager.compose.patcher.alignment.ZipAligner
import app.revanced.manager.compose.patcher.alignment.zip.ZipFile
import app.revanced.manager.compose.patcher.alignment.zip.structures.ZipEntry
import app.revanced.patcher.PatcherResult
import java.io.File
// This is the same aligner used by the CLI.
// It will be removed eventually.
object Aligning {
fun align(result: PatcherResult, inputFile: File, outputFile: File) {
// logger.info("Aligning ${inputFile.name} to ${outputFile.name}")
if (outputFile.exists()) outputFile.delete()
ZipFile(outputFile).use { file ->
result.dexFiles.forEach {
file.addEntryCompressData(
ZipEntry.createWithName(it.name),
it.stream.readBytes()
)
}
result.resourceFile?.let {
file.copyEntriesFromFileAligned(
ZipFile(it),
ZipAligner::getEntryAlignment
)
}
file.copyEntriesFromFileAligned(
ZipFile(inputFile),
ZipAligner::getEntryAlignment
)
}
}
}

View File

@ -0,0 +1,100 @@
package app.revanced.manager.compose.patcher
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherOptions
import app.revanced.patcher.logging.Logger
import android.util.Log
import app.revanced.manager.compose.patcher.worker.Progress
import app.revanced.patcher.data.Context
import app.revanced.patcher.patch.Patch
import java.io.Closeable
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
internal typealias PatchClass = Class<out Patch<Context>>
internal typealias PatchList = List<PatchClass>
class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
private val input: File,
private val onProgress: suspend (Progress) -> Unit = { }
) : Closeable {
class PatchFailedException(val patchName: String, cause: Throwable?) : Exception("Got exception while executing $patchName", cause)
private val logger = LogcatLogger
private val temporary = File(cacheDir).resolve("manager").also { it.mkdirs() }
private val patcher = Patcher(
PatcherOptions(
inputFile = input,
resourceCacheDirectory = temporary.resolve("aapt-resources").path,
frameworkFolderLocation = frameworkDir,
aaptPath = aaptPath,
logger = logger,
)
)
private suspend fun Patcher.applyPatchesVerbose() {
this.executePatches(true).forEach { (patch, result) ->
if (result.isSuccess) {
logger.info("$patch succeeded")
onProgress(Progress.PatchSuccess(patch))
return@forEach
}
logger.error("$patch failed:")
result.exceptionOrNull()!!.printStackTrace()
throw PatchFailedException(patch, result.exceptionOrNull())
}
}
suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
onProgress(Progress.Merging)
with(patcher) {
logger.info("Merging integrations")
addIntegrations(integrations) {}
addPatches(selectedPatches)
logger.info("Applying patches...")
onProgress(Progress.PatchingStart)
applyPatchesVerbose()
}
onProgress(Progress.Saving)
logger.info("Writing patched files...")
val result = patcher.save()
val aligned = temporary.resolve("aligned.apk").also { Aligning.align(result, input, it) }
logger.info("Patched apk saved to $aligned")
Files.move(aligned.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
override fun close() {
temporary.delete()
}
}
private object LogcatLogger : Logger {
private const val tag = "revanced-patcher"
override fun error(msg: String) {
Log.e(tag, msg)
}
override fun warn(msg: String) {
Log.w(tag, msg)
}
override fun info(msg: String) {
Log.i(tag, msg)
}
override fun trace(msg: String) {
Log.v(tag, msg)
}
}

View File

@ -0,0 +1,13 @@
package app.revanced.manager.compose.patcher.aapt
import android.content.Context
import java.io.File
object Aapt {
fun binary(context: Context): File? {
return File(context.applicationInfo.nativeLibraryDir).resolveAapt()
}
}
private fun File.resolveAapt() =
list { _, f -> !File(f).isDirectory && f.contains("aapt") }?.firstOrNull()?.let { resolve(it) }

View File

@ -0,0 +1,11 @@
package app.revanced.manager.compose.patcher.alignment
import app.revanced.manager.compose.patcher.alignment.zip.structures.ZipEntry
internal object ZipAligner {
private const val DEFAULT_ALIGNMENT = 4
private const val LIBRARY_ALIGNMENT = 4096
fun getEntryAlignment(entry: ZipEntry): Int? =
if (entry.compression.toUInt() != 0u) null else if (entry.fileName.endsWith(".so")) LIBRARY_ALIGNMENT else DEFAULT_ALIGNMENT
}

View File

@ -0,0 +1,33 @@
package app.revanced.manager.compose.patcher.alignment.zip
import java.io.DataInput
import java.io.DataOutput
import java.nio.ByteBuffer
fun UInt.toLittleEndian() =
(((this.toInt() and 0xff000000.toInt()) shr 24) or ((this.toInt() and 0x00ff0000) shr 8) or ((this.toInt() and 0x0000ff00) shl 8) or (this.toInt() shl 24)).toUInt()
fun UShort.toLittleEndian() = (this.toUInt() shl 16).toLittleEndian().toUShort()
fun UInt.toBigEndian() = (((this.toInt() and 0xff) shl 24) or ((this.toInt() and 0xff00) shl 8)
or ((this.toInt() and 0x00ff0000) ushr 8) or (this.toInt() ushr 24)).toUInt()
fun UShort.toBigEndian() = (this.toUInt() shl 16).toBigEndian().toUShort()
fun ByteBuffer.getUShort() = this.getShort().toUShort()
fun ByteBuffer.getUInt() = this.getInt().toUInt()
fun ByteBuffer.putUShort(ushort: UShort) = this.putShort(ushort.toShort())
fun ByteBuffer.putUInt(uint: UInt) = this.putInt(uint.toInt())
fun DataInput.readUShort() = this.readShort().toUShort()
fun DataInput.readUInt() = this.readInt().toUInt()
fun DataOutput.writeUShort(ushort: UShort) = this.writeShort(ushort.toInt())
fun DataOutput.writeUInt(uint: UInt) = this.writeInt(uint.toInt())
fun DataInput.readUShortLE() = this.readUShort().toBigEndian()
fun DataInput.readUIntLE() = this.readUInt().toBigEndian()
fun DataOutput.writeUShortLE(ushort: UShort) = this.writeUShort(ushort.toLittleEndian())
fun DataOutput.writeUIntLE(uint: UInt) = this.writeUInt(uint.toLittleEndian())

View File

@ -0,0 +1,177 @@
package app.revanced.manager.compose.patcher.alignment.zip
import app.revanced.manager.compose.patcher.alignment.zip.structures.ZipEndRecord
import app.revanced.manager.compose.patcher.alignment.zip.structures.ZipEntry
import java.io.Closeable
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import java.util.zip.CRC32
import java.util.zip.Deflater
class ZipFile(file: File) : Closeable {
var entries: MutableList<ZipEntry> = mutableListOf()
private val filePointer: RandomAccessFile = RandomAccessFile(file, "rw")
private var CDNeedsRewrite = false
private val compressionLevel = 5
init {
//if file isn't empty try to load entries
if (file.length() > 0) {
val endRecord = findEndRecord()
if (endRecord.diskNumber > 0u || endRecord.totalEntries != endRecord.diskEntries)
throw IllegalArgumentException("Multi-file archives are not supported")
entries = readEntries(endRecord).toMutableList()
}
//seek back to start for writing
filePointer.seek(0)
}
private fun findEndRecord(): ZipEndRecord {
//look from end to start since end record is at the end
for (i in filePointer.length() - 1 downTo 0) {
filePointer.seek(i)
//possible beginning of signature
if (filePointer.readByte() == 0x50.toByte()) {
//seek back to get the full int
filePointer.seek(i)
val possibleSignature = filePointer.readUIntLE()
if (possibleSignature == ZipEndRecord.ECD_SIGNATURE) {
filePointer.seek(i)
return ZipEndRecord.fromECD(filePointer)
}
}
}
throw Exception("Couldn't find end record")
}
private fun readEntries(endRecord: ZipEndRecord): List<ZipEntry> {
filePointer.seek(endRecord.centralDirectoryStartOffset.toLong())
val numberOfEntries = endRecord.diskEntries.toInt()
return buildList(numberOfEntries) {
for (i in 1..numberOfEntries) {
add(
ZipEntry.fromCDE(filePointer).also
{
//for some reason the local extra field can be different from the central one
it.readLocalExtra(
filePointer.channel.map(
FileChannel.MapMode.READ_ONLY,
it.localHeaderOffset.toLong() + 28,
2
)
)
})
}
}
}
private fun writeCD() {
val CDStart = filePointer.channel.position().toUInt()
entries.forEach {
filePointer.channel.write(it.toCDE())
}
val entriesCount = entries.size.toUShort()
val endRecord = ZipEndRecord(
0u,
0u,
entriesCount,
entriesCount,
filePointer.channel.position().toUInt() - CDStart,
CDStart,
""
)
filePointer.channel.write(endRecord.toECD())
}
private fun addEntry(entry: ZipEntry, data: ByteBuffer) {
CDNeedsRewrite = true
entry.localHeaderOffset = filePointer.channel.position().toUInt()
filePointer.channel.write(entry.toLFH())
filePointer.channel.write(data)
entries.add(entry)
}
fun addEntryCompressData(entry: ZipEntry, data: ByteArray) {
val compressor = Deflater(compressionLevel, true)
compressor.setInput(data)
compressor.finish()
val uncompressedSize = data.size
val compressedData =
ByteArray(uncompressedSize) //i'm guessing compression won't make the data bigger
val compressedDataLength = compressor.deflate(compressedData)
val compressedBuffer =
ByteBuffer.wrap(compressedData.take(compressedDataLength).toByteArray())
compressor.end()
val crc = CRC32()
crc.update(data)
entry.compression = 8u //deflate compression
entry.uncompressedSize = uncompressedSize.toUInt()
entry.compressedSize = compressedDataLength.toUInt()
entry.crc32 = crc.value.toUInt()
addEntry(entry, compressedBuffer)
}
private fun addEntryCopyData(entry: ZipEntry, data: ByteBuffer, alignment: Int? = null) {
alignment?.let {
//calculate where data would end up
val dataOffset = filePointer.filePointer + entry.LFHSize
val mod = dataOffset % alignment
//wrong alignment
if (mod != 0L) {
//add padding at end of extra field
entry.localExtraField =
entry.localExtraField.copyOf((entry.localExtraField.size + (alignment - mod)).toInt())
}
}
addEntry(entry, data)
}
fun getDataForEntry(entry: ZipEntry): ByteBuffer {
return filePointer.channel.map(
FileChannel.MapMode.READ_ONLY,
entry.dataOffset.toLong(),
entry.compressedSize.toLong()
)
}
fun copyEntriesFromFileAligned(file: ZipFile, entryAlignment: (entry: ZipEntry) -> Int?) {
for (entry in file.entries) {
if (entries.any { it.fileName == entry.fileName }) continue //don't add duplicates
val data = file.getDataForEntry(entry)
addEntryCopyData(entry, data, entryAlignment(entry))
}
}
override fun close() {
if (CDNeedsRewrite) writeCD()
filePointer.close()
}
}

View File

@ -0,0 +1,74 @@
package app.revanced.manager.compose.patcher.alignment.zip.structures
import app.revanced.manager.compose.patcher.alignment.zip.*
import java.io.DataInput
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class ZipEndRecord(
val diskNumber: UShort,
val startingDiskNumber: UShort,
val diskEntries: UShort,
val totalEntries: UShort,
val centralDirectorySize: UInt,
val centralDirectoryStartOffset: UInt,
val fileComment: String,
) {
companion object {
const val ECD_HEADER_SIZE = 22
const val ECD_SIGNATURE = 0x06054b50u
fun fromECD(input: DataInput): ZipEndRecord {
val signature = input.readUIntLE()
if (signature != ECD_SIGNATURE)
throw IllegalArgumentException("Input doesn't start with end record signature")
val diskNumber = input.readUShortLE()
val startingDiskNumber = input.readUShortLE()
val diskEntries = input.readUShortLE()
val totalEntries = input.readUShortLE()
val centralDirectorySize = input.readUIntLE()
val centralDirectoryStartOffset = input.readUIntLE()
val fileCommentLength = input.readUShortLE()
var fileComment = ""
if (fileCommentLength > 0u) {
val fileCommentBytes = ByteArray(fileCommentLength.toInt())
input.readFully(fileCommentBytes)
fileComment = fileCommentBytes.toString(Charsets.UTF_8)
}
return ZipEndRecord(
diskNumber,
startingDiskNumber,
diskEntries,
totalEntries,
centralDirectorySize,
centralDirectoryStartOffset,
fileComment
)
}
}
fun toECD(): ByteBuffer {
val commentBytes = fileComment.toByteArray(Charsets.UTF_8)
val buffer = ByteBuffer.allocate(ECD_HEADER_SIZE + commentBytes.size).also { it.order(ByteOrder.LITTLE_ENDIAN) }
buffer.putUInt(ECD_SIGNATURE)
buffer.putUShort(diskNumber)
buffer.putUShort(startingDiskNumber)
buffer.putUShort(diskEntries)
buffer.putUShort(totalEntries)
buffer.putUInt(centralDirectorySize)
buffer.putUInt(centralDirectoryStartOffset)
buffer.putUShort(commentBytes.size.toUShort())
buffer.put(commentBytes)
buffer.flip()
return buffer
}
}

View File

@ -0,0 +1,189 @@
package app.revanced.manager.compose.patcher.alignment.zip.structures
import app.revanced.manager.compose.patcher.alignment.zip.*
import java.io.DataInput
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class ZipEntry(
val version: UShort,
val versionNeeded: UShort,
val flags: UShort,
var compression: UShort,
val modificationTime: UShort,
val modificationDate: UShort,
var crc32: UInt,
var compressedSize: UInt,
var uncompressedSize: UInt,
val diskNumber: UShort,
val internalAttributes: UShort,
val externalAttributes: UInt,
var localHeaderOffset: UInt,
val fileName: String,
val extraField: ByteArray,
val fileComment: String,
var localExtraField: ByteArray = ByteArray(0), //separate for alignment
) {
val LFHSize: Int
get() = LFH_HEADER_SIZE + fileName.toByteArray(Charsets.UTF_8).size + localExtraField.size
val dataOffset: UInt
get() = localHeaderOffset + LFHSize.toUInt()
companion object {
const val CDE_HEADER_SIZE = 46
const val CDE_SIGNATURE = 0x02014b50u
const val LFH_HEADER_SIZE = 30
const val LFH_SIGNATURE = 0x04034b50u
fun createWithName(fileName: String): ZipEntry {
return ZipEntry(
0x1403u, //made by unix, version 20
0u,
0u,
0u,
0x0821u, //seems to be static time google uses, no idea
0x0221u, //same as above
0u,
0u,
0u,
0u,
0u,
0u,
0u,
fileName,
ByteArray(0),
""
)
}
fun fromCDE(input: DataInput): ZipEntry {
val signature = input.readUIntLE()
if (signature != CDE_SIGNATURE)
throw IllegalArgumentException("Input doesn't start with central directory entry signature")
val version = input.readUShortLE()
val versionNeeded = input.readUShortLE()
var flags = input.readUShortLE()
val compression = input.readUShortLE()
val modificationTime = input.readUShortLE()
val modificationDate = input.readUShortLE()
val crc32 = input.readUIntLE()
val compressedSize = input.readUIntLE()
val uncompressedSize = input.readUIntLE()
val fileNameLength = input.readUShortLE()
var fileName = ""
val extraFieldLength = input.readUShortLE()
val extraField = ByteArray(extraFieldLength.toInt())
val fileCommentLength = input.readUShortLE()
var fileComment = ""
val diskNumber = input.readUShortLE()
val internalAttributes = input.readUShortLE()
val externalAttributes = input.readUIntLE()
val localHeaderOffset = input.readUIntLE()
val variableFieldsLength =
fileNameLength.toInt() + extraFieldLength.toInt() + fileCommentLength.toInt()
if (variableFieldsLength > 0) {
val fileNameBytes = ByteArray(fileNameLength.toInt())
input.readFully(fileNameBytes)
fileName = fileNameBytes.toString(Charsets.UTF_8)
input.readFully(extraField)
val fileCommentBytes = ByteArray(fileCommentLength.toInt())
input.readFully(fileCommentBytes)
fileComment = fileCommentBytes.toString(Charsets.UTF_8)
}
flags = (flags and 0b1000u.inv()
.toUShort()) //disable data descriptor flag as they are not used
return ZipEntry(
version,
versionNeeded,
flags,
compression,
modificationTime,
modificationDate,
crc32,
compressedSize,
uncompressedSize,
diskNumber,
internalAttributes,
externalAttributes,
localHeaderOffset,
fileName,
extraField,
fileComment,
)
}
}
fun readLocalExtra(buffer: ByteBuffer) {
buffer.order(ByteOrder.LITTLE_ENDIAN)
localExtraField = ByteArray(buffer.getUShort().toInt())
}
fun toLFH(): ByteBuffer {
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
val buffer = ByteBuffer.allocate(LFH_HEADER_SIZE + nameBytes.size + localExtraField.size)
.also { it.order(ByteOrder.LITTLE_ENDIAN) }
buffer.putUInt(LFH_SIGNATURE)
buffer.putUShort(versionNeeded)
buffer.putUShort(flags)
buffer.putUShort(compression)
buffer.putUShort(modificationTime)
buffer.putUShort(modificationDate)
buffer.putUInt(crc32)
buffer.putUInt(compressedSize)
buffer.putUInt(uncompressedSize)
buffer.putUShort(nameBytes.size.toUShort())
buffer.putUShort(localExtraField.size.toUShort())
buffer.put(nameBytes)
buffer.put(localExtraField)
buffer.flip()
return buffer
}
fun toCDE(): ByteBuffer {
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
val commentBytes = fileComment.toByteArray(Charsets.UTF_8)
val buffer =
ByteBuffer.allocate(CDE_HEADER_SIZE + nameBytes.size + extraField.size + commentBytes.size)
.also { it.order(ByteOrder.LITTLE_ENDIAN) }
buffer.putUInt(CDE_SIGNATURE)
buffer.putUShort(version)
buffer.putUShort(versionNeeded)
buffer.putUShort(flags)
buffer.putUShort(compression)
buffer.putUShort(modificationTime)
buffer.putUShort(modificationDate)
buffer.putUInt(crc32)
buffer.putUInt(compressedSize)
buffer.putUInt(uncompressedSize)
buffer.putUShort(nameBytes.size.toUShort())
buffer.putUShort(extraField.size.toUShort())
buffer.putUShort(commentBytes.size.toUShort())
buffer.putUShort(diskNumber)
buffer.putUShort(internalAttributes)
buffer.putUInt(externalAttributes)
buffer.putUInt(localHeaderOffset)
buffer.put(nameBytes)
buffer.put(extraField)
buffer.put(commentBytes)
buffer.flip()
return buffer
}
}

View File

@ -0,0 +1,40 @@
package app.revanced.manager.compose.patcher.data
import app.revanced.manager.compose.patcher.PatchClass
import app.revanced.patcher.Patcher
import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
import app.revanced.patcher.util.patch.PatchBundle
import dalvik.system.PathClassLoader
import java.io.File
class PatchBundle(private val loader: Iterable<PatchClass>, val integrations: File?) {
constructor(bundleJar: String, integrations: File?) : this(
object : Iterable<PatchClass> {
private val bundle = PatchBundle.Dex(
bundleJar,
PathClassLoader(bundleJar, Patcher::class.java.classLoader)
)
override fun iterator() = bundle.loadPatches().iterator()
},
integrations
)
/**
* @return A list of patches that are compatible with this Apk.
*/
fun loadPatchesFiltered(packageName: String) = loader.filter { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
if (!compatiblePackages.any { it.name == packageName }) {
// Patch is not compatible with this package.
return@filter false
}
true
}
fun loadAllPatches() = loader.toList()
}

View File

@ -0,0 +1,43 @@
package app.revanced.manager.compose.patcher.data.repository
import app.revanced.manager.compose.network.api.ManagerAPI
import app.revanced.manager.compose.patcher.data.PatchBundle
import app.revanced.manager.compose.patcher.patch.PatchInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
class PatchesRepository(private val managerAPI: ManagerAPI) {
private val patchInformation = MutableSharedFlow<List<PatchInfo>>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var bundle: PatchBundle? = null
private val scope = CoroutineScope(Job() + Dispatchers.IO)
/**
* Load a new bundle and update state associated with it.
*/
private suspend fun loadNewBundle(new: PatchBundle) {
bundle = new
withContext(Dispatchers.Main) {
patchInformation.emit(new.loadAllPatches().map { PatchInfo(it) })
}
}
/**
* Get the [PatchBundle], loading it if needed.
*/
private suspend fun getBundle() = bundle ?: PatchBundle(
managerAPI.downloadPatchBundle()!!.absolutePath,
managerAPI.downloadIntegrations()
).also {
loadNewBundle(it)
}
suspend fun loadPatchClassesFiltered(packageName: String) =
getBundle().loadPatchesFiltered(packageName)
fun getPatchInformation() = patchInformation.asSharedFlow().also { scope.launch { getBundle() } }
suspend fun getIntegrations() = listOfNotNull(getBundle().integrations)
}

View File

@ -0,0 +1,46 @@
package app.revanced.manager.compose.patcher.patch
import android.os.Parcelable
import app.revanced.manager.compose.patcher.PatchClass
import app.revanced.patcher.annotation.Package
import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
import app.revanced.patcher.extensions.PatchExtensions.dependencies
import app.revanced.patcher.extensions.PatchExtensions.description
import app.revanced.patcher.extensions.PatchExtensions.include
import app.revanced.patcher.extensions.PatchExtensions.options
import app.revanced.patcher.extensions.PatchExtensions.patchName
import app.revanced.patcher.patch.PatchOption
import kotlinx.parcelize.Parcelize
@Parcelize
data class PatchInfo(
val name: String,
val description: String?,
val dependencies: List<String>?,
val include: Boolean,
val compatiblePackages: List<CompatiblePackage>?,
val options: List<Option>?
) : Parcelable {
constructor(patch: PatchClass) : this(
patch.patchName,
patch.description,
patch.dependencies?.map { it.java.patchName },
patch.include,
patch.compatiblePackages?.map { CompatiblePackage(it) },
patch.options?.map { Option(it) })
fun compatibleWith(packageName: String) = compatiblePackages?.any { it.name == packageName } ?: true
fun supportsVersion(versionName: String) =
compatiblePackages?.any { compatiblePackages.any { it.versions.isEmpty() || it.versions.any { version -> version == versionName } } } ?: true
}
@Parcelize
data class CompatiblePackage(val name: String, val versions: List<String>) : Parcelable {
constructor(pkg: Package) : this(pkg.name, pkg.versions.toList())
}
@Parcelize
data class Option(val title: String, val key: String, val description: String, val required: Boolean) : Parcelable {
constructor(option: PatchOption<*>) : this(option.title, option.key, option.description, option.required)
}

View File

@ -0,0 +1,140 @@
package app.revanced.manager.compose.patcher.worker
import android.content.Context
import androidx.annotation.StringRes
import androidx.work.Data
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
sealed class Progress {
object Unpacking : Progress()
object Merging : Progress()
object PatchingStart : Progress()
data class PatchSuccess(val patchName: String) : Progress()
object Saving : Progress()
}
@Serializable
enum class StepStatus {
WAITING,
COMPLETED,
FAILURE,
}
@Serializable
class Step(val name: String, val status: StepStatus = StepStatus.WAITING)
@Serializable
class StepGroup(@StringRes val name: Int, val steps: List<Step>, val status: StepStatus = StepStatus.WAITING)
class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
val stepGroups = generateGroupsList(context, selectedPatches)
companion object {
private const val PATCHES = 1
private const val WORK_DATA_KEY = "progress"
/**
* A map of [Session.Progress] to the corresponding position in [stepGroups]
*/
private val stepKeyMap = mapOf(
Progress.Unpacking to StepKey(0, 0),
Progress.Merging to StepKey(0, 1),
Progress.PatchingStart to StepKey(PATCHES, 0),
Progress.Saving to StepKey(2, 0),
)
fun generateGroupsList(context: Context, selectedPatches: List<String>) = mutableListOf(
StepGroup(
R.string.patcher_step_group_prepare,
listOf(
Step(context.getString(R.string.patcher_step_unpack)),
Step(context.getString(R.string.patcher_step_integrations))
)
),
StepGroup(
R.string.patcher_step_group_patching,
selectedPatches.map { Step(it) }
),
StepGroup(
R.string.patcher_step_group_saving,
listOf(Step(context.getString(R.string.patcher_step_write_patched)))
)
)
fun groupsFromWorkData(workData: Data) = workData.getString(WORK_DATA_KEY)
?.let { Json.decodeFromString<List<StepGroup>>(it) }
}
fun groupsToWorkData() = workDataOf(WORK_DATA_KEY to Json.Default.encodeToString(stepGroups))
private var currentStep: StepKey? = null
private fun <T> MutableList<T>.mutateIndex(index: Int, callback: (T) -> T) = apply {
this[index] = callback(this[index])
}
private fun updateStepStatus(key: StepKey, newStatus: StepStatus) {
var isLastStepOfGroup = false
stepGroups.mutateIndex(key.groupIndex) { group ->
isLastStepOfGroup = key.stepIndex == group.steps.size - 1
val newGroupStatus = when {
// This group failed if a step in it failed.
newStatus == StepStatus.FAILURE -> StepStatus.FAILURE
// All steps in the group succeeded.
newStatus == StepStatus.COMPLETED && isLastStepOfGroup -> StepStatus.COMPLETED
// Keep the old status.
else -> group.status
}
StepGroup(group.name, group.steps.toMutableList().mutateIndex(key.stepIndex) { step ->
Step(step.name, newStatus)
}, newGroupStatus)
}
val isFinalStep = isLastStepOfGroup && key.groupIndex == stepGroups.size - 1
if (newStatus == StepStatus.COMPLETED) {
// Move the cursor to the next step.
currentStep = when {
isFinalStep -> null // Final step has been completed.
isLastStepOfGroup -> StepKey(key.groupIndex + 1, 0) // Move to the next group.
else -> StepKey(key.groupIndex, key.stepIndex + 1) // Move to the next step of this group.
}
}
}
private fun setCurrentStepStatus(newStatus: StepStatus) = currentStep?.let { updateStepStatus(it, newStatus) }
private data class StepKey(val groupIndex: Int, val stepIndex: Int)
fun handle(progress: Progress) {
if (progress is Progress.PatchSuccess) {
val patchStepKey = StepKey(
PATCHES,
stepGroups[PATCHES].steps.indexOfFirst { it.name == progress.patchName })
updateStepStatus(patchStepKey, StepStatus.COMPLETED)
} else {
currentStep?.let { updateStepStatus(it, StepStatus.COMPLETED) }
currentStep = stepKeyMap[progress]!!
}
}
fun failure() {
// TODO: associate the exception with the step that just failed.
setCurrentStepStatus(StepStatus.FAILURE)
}
fun success() {
setCurrentStepStatus(StepStatus.COMPLETED)
}
}

View File

@ -0,0 +1,79 @@
package app.revanced.manager.compose.patcher.worker
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import app.revanced.manager.compose.patcher.data.repository.PatchesRepository
import app.revanced.manager.compose.patcher.Session
import app.revanced.manager.compose.patcher.aapt.Aapt
import app.revanced.patcher.extensions.PatchExtensions.patchName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
import java.io.FileNotFoundException
// TODO: setup wakelock + notification so android doesn't murder us.
class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters),
KoinComponent {
private val patchesRepository: PatchesRepository by inject()
@Serializable
data class Args(
val input: String,
val output: String,
val selectedPatches: List<String>,
val packageName: String,
val packageVersion: String
)
companion object {
const val ARGS_KEY = "args"
}
override suspend fun doWork(): Result {
if (runAttemptCount > 0) {
Log.d("revanced-worker", "Android requested retrying but retrying is disabled.")
return Result.failure()
}
val aaptPath =
Aapt.binary(applicationContext)?.absolutePath ?: throw FileNotFoundException("Could not resolve aapt.")
val frameworkPath =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val args = Json.decodeFromString<Args>(inputData.getString(ARGS_KEY)!!)
val selected = args.selectedPatches.toSet()
val patchList = patchesRepository.loadPatchClassesFiltered(args.packageName)
.filter { selected.contains(it.patchName) }
val progressManager = PatcherProgressManager(applicationContext, args.selectedPatches)
suspend fun updateProgress(progress: Progress) {
progressManager.handle(progress)
setProgress(progressManager.groupsToWorkData())
}
updateProgress(Progress.Unpacking)
return try {
Session(applicationContext.cacheDir.path, frameworkPath, aaptPath, File(args.input)) {
updateProgress(it)
}.use { session ->
session.run(File(args.output), patchList, patchesRepository.getIntegrations())
}
Log.i("revanced-worker", "Patching succeeded")
progressManager.success()
Result.success(progressManager.groupsToWorkData())
} catch (e: Throwable) {
Log.e("revanced-worker", "Got exception while patching", e)
progressManager.failure()
Result.failure(progressManager.groupsToWorkData())
}
}
}

View File

@ -0,0 +1,47 @@
package app.revanced.manager.compose.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import app.revanced.manager.compose.patcher.patch.PatchInfo
@Composable
fun PatchItem(
patch: PatchInfo,
onOptionsDialog: () -> Unit,
selected: Boolean,
onToggle: () -> Unit,
supported: Boolean
) {
ListItem(
modifier = Modifier
.let { if (!supported) it.alpha(0.5f) else it }
.clickable(enabled = supported, onClick = onToggle),
leadingContent = {
Checkbox(
checked = selected,
onCheckedChange = {
onToggle()
},
enabled = supported
)
},
headlineContent = {
Text(patch.name)
},
supportingContent = {
Text(patch.description ?: "")
},
trailingContent = {
if (patch.options?.isNotEmpty() == true) {
IconButton(onClick = onOptionsDialog, enabled = supported) {
Icon(Icons.Outlined.Settings, null)
}
}
}
)
}

View File

@ -1,7 +1,9 @@
package app.revanced.manager.compose.ui.destination
import android.os.Parcelable
import app.revanced.manager.compose.util.PackageInfo
import kotlinx.parcelize.Parcelize
import java.io.File
sealed interface Destination: Parcelable {
@ -15,6 +17,8 @@ sealed interface Destination: Parcelable {
object Settings: Destination
@Parcelize
object PatchesSelector: Destination
data class PatchesSelector(val input: PackageInfo): Destination
@Parcelize
data class Installer(val input: PackageInfo, val selectedPatches: List<String>) : Destination
}

View File

@ -1,5 +1,7 @@
package app.revanced.manager.compose.ui.screen
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -22,14 +24,22 @@ import app.revanced.manager.compose.R
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.PM
import app.revanced.manager.compose.util.PackageInfo
import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onAppClick: () -> Unit,
onBackClick: () -> Unit
onAppClick: (PackageInfo) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = getViewModel()
) {
val pickApkLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { apkUri ->
vm.loadSelectedFile(apkUri!!).let(onAppClick)
}
var filterText by rememberSaveable { mutableStateOf("") }
var search by rememberSaveable { mutableStateOf(false) }
@ -61,11 +71,11 @@ fun AppSelectorScreen(
) { app ->
ListItem(
modifier = Modifier.clickable { onAppClick() },
modifier = Modifier.clickable { onAppClick(PackageInfo(app)) },
leadingContent = { AppIcon(app.icon, null, 36) },
headlineContent = { Text(app.label) },
supportingContent = { Text(app.packageName) },
trailingContent = { Text((PM.testList[app.packageName]?: 0).let { if (it == 1) "$it " + stringResource(R.string.patch) else "$it " + stringResource(R.string.patches) }) }
trailingContent = { Text("420 Patches") }
)
}
@ -106,7 +116,9 @@ fun AppSelectorScreen(
item {
ListItem(
modifier = Modifier.clickable { },
modifier = Modifier.clickable {
pickApkLauncher.launch("*/*")
},
leadingContent = { Box(Modifier.size(36.dp), Alignment.Center) { Icon(Icons.Default.Storage, null, modifier = Modifier.size(24.dp)) } },
headlineContent = { Text(stringResource(R.string.select_from_storage)) }
)
@ -124,14 +136,12 @@ fun AppSelectorScreen(
val app = list[index]
ListItem(
modifier = Modifier.clickable { onAppClick() },
modifier = Modifier.clickable { onAppClick(PackageInfo(app)) },
leadingContent = { AppIcon(app.icon, null, 36) },
headlineContent = { Text(app.label) },
supportingContent = { Text(app.packageName) },
trailingContent = {
Text(
(PM.testList[app.packageName]?: 0).let { if (it == 1) "$it " + stringResource(R.string.patch) else "$it " + stringResource(R.string.patches) }
)
Text("420 Patches")
}
)
@ -146,7 +156,6 @@ fun AppSelectorScreen(
}
}
}
} else {
LoadingIndicator()
}

View File

@ -33,7 +33,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.compose.R
import app.revanced.manager.compose.ui.component.AppScaffold
import app.revanced.manager.compose.ui.component.AppTopBar
import kotlinx.coroutines.launch

View File

@ -0,0 +1,50 @@
package app.revanced.manager.compose.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InstallerScreen(
vm: InstallerScreenViewModel
) {
AppScaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.installer),
onBackClick = { },
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
vm.stepGroups.forEach {
Column {
Text(
text = "${stringResource(it.name)}: ${it.status}",
style = MaterialTheme.typography.titleLarge
)
it.steps.forEach {
Text(
text = "${it.name}: ${it.status}",
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
}
}

View File

@ -1,7 +1,6 @@
package app.revanced.manager.compose.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -16,15 +15,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
@ -34,72 +30,54 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.compose.R
import app.revanced.manager.compose.ui.component.AppTopBar
import app.revanced.manager.compose.ui.component.GroupHeader
import app.revanced.manager.compose.ui.component.PatchItem
import app.revanced.manager.compose.ui.viewmodel.PatchesSelectorViewModel
import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel
import java.io.File
const val allowUnsupported = false
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PatchesSelectorScreen(
selectedApp: List<File>? = null,
onBackClick: () -> Unit,
viewModel: PatchesSelectorViewModel = getViewModel()
startPatching: (List<String>) -> Unit, onBackClick: () -> Unit, vm: PatchesSelectorViewModel
) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
var showOptionsDialog by rememberSaveable { mutableStateOf(false) }
var showUnsupportedDialog by rememberSaveable { mutableStateOf(false) }
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList())
if (showUnsupportedDialog)
UnsupportedDialog(onDismissRequest = { showUnsupportedDialog = false })
if (vm.showUnsupportedDialog) UnsupportedDialog(onDismissRequest = vm::dismissDialogs)
if (showOptionsDialog)
OptionsDialog(
onDismissRequest = { showOptionsDialog = false },
onConfirm = {}
)
if (vm.showOptionsDialog) OptionsDialog(onDismissRequest = vm::dismissDialogs, onConfirm = {})
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.select_patches),
onBackClick = onBackClick,
actions = {
IconButton(onClick = { }) {
Icon(Icons.Outlined.HelpOutline, stringResource(R.string.help))
}
IconButton(onClick = { }) {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = { Icon(Icons.Default.Build, null) },
onClick = { /*TODO*/ })
}
) { paddingValues ->
Scaffold(topBar = {
AppTopBar(title = stringResource(R.string.select_patches), onBackClick = onBackClick, actions = {
IconButton(onClick = { }) {
Icon(Icons.Outlined.HelpOutline, stringResource(R.string.help))
}
IconButton(onClick = { }) {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
}
})
}, floatingActionButton = {
ExtendedFloatingActionButton(text = { Text(stringResource(R.string.patch)) },
icon = { Icon(Icons.Default.Build, null) },
onClick = { startPatching(vm.selectedPatches) })
}) { paddingValues ->
Column(Modifier.fillMaxSize().padding(paddingValues)) {
TabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
viewModel.bundles.forEachIndexed { index, bundle ->
bundles.forEachIndexed { index, bundle ->
Tab(
selected = pagerState.currentPage == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
@ -111,68 +89,40 @@ fun PatchesSelectorScreen(
}
HorizontalPager(
pageCount = viewModel.bundles.size,
pageCount = bundles.size,
state = pagerState,
userScrollEnabled = true,
pageContent = { index ->
val patches = rememberSaveable { viewModel.bundles[index].patches }
val bundle = bundles[index]
LazyColumn(
modifier = Modifier
.fillMaxSize()
modifier = Modifier.fillMaxSize()
) {
items(
items = patches["supported"]!!
items = bundle.supported
) { patch ->
ListItem(
modifier = Modifier.clickable {
if (viewModel.selectedPatches.contains(patch))
viewModel.selectedPatches.remove(patch)
else
viewModel.selectedPatches.add(patch)
PatchItem(
patch = patch,
onOptionsDialog = vm::openOptionsDialog,
onToggle = {
vm.togglePatch(patch)
},
leadingContent = {
Checkbox(
checked = viewModel.selectedPatches.contains(patch),
onCheckedChange = {
if (viewModel.selectedPatches.contains(patch))
viewModel.selectedPatches.remove(patch)
else
viewModel.selectedPatches.add(patch)
}
)
},
headlineContent = {
Text(patch.name)
},
supportingContent = {
Text(patch.description)
},
trailingContent = {
if (patch.options.isNotEmpty()) {
IconButton(onClick = { showOptionsDialog = true }) {
Icon(Icons.Outlined.Settings, null)
}
}
}
selected = vm.isSelected(patch),
supported = true
)
}
if (patches["unsupported"]!!.isNotEmpty()) {
if (bundle.unsupported.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.padding(end = 10.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp).padding(end = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
GroupHeader("Unsupported patches", Modifier.padding(0.dp))
IconButton(onClick = { showUnsupportedDialog = true }) {
GroupHeader(stringResource(R.string.unsupported_patches), Modifier.padding(0.dp))
IconButton(onClick = vm::openUnsupportedDialog) {
Icon(
Icons.Outlined.HelpOutline,
stringResource(R.string.help)
Icons.Outlined.HelpOutline, stringResource(R.string.help)
)
}
}
@ -180,53 +130,23 @@ fun PatchesSelectorScreen(
}
items(
items = patches["unsupported"]!!,
items = bundle.unsupported,
// key = { it.name }
) { patch ->
ListItem(
modifier = Modifier
.alpha(0.5f)
.clickable(enabled = false) {
if (viewModel.selectedPatches.contains(patch))
viewModel.selectedPatches.remove(patch)
else
viewModel.selectedPatches.add(patch)
},
leadingContent = {
Checkbox(
checked = viewModel.selectedPatches.contains(patch),
onCheckedChange = {
if (viewModel.selectedPatches.contains(patch))
viewModel.selectedPatches.remove(patch)
else
viewModel.selectedPatches.add(patch)
},
enabled = false
)
PatchItem(
patch = patch,
onOptionsDialog = vm::openOptionsDialog,
onToggle = {
vm.togglePatch(patch)
},
headlineContent = {
Text(patch.name)
},
supportingContent = {
Text(patch.description)
},
trailingContent = {
if (patch.options.isNotEmpty()) {
IconButton(onClick = { showOptionsDialog = true }, enabled = false) {
Icon(Icons.Outlined.Settings, null)
}
}
}
selected = vm.isSelected(patch),
supported = allowUnsupported
)
}
}
}
)
})
}
}
}
@ -236,10 +156,10 @@ fun UnsupportedDialog(
onDismissRequest: () -> Unit
) {
val appVersion = "1.1.0"
val supportedVersions = listOf("1.1.1", "1.2.0", "1.1.1", "1.2.0", "1.1.1", "1.2.0", "1.1.1", "1.2.0", "1.1.1", "1.2.0")
val supportedVersions =
listOf("1.1.1", "1.2.0", "1.1.1", "1.2.0", "1.1.1", "1.2.0", "1.1.1", "1.2.0", "1.1.1", "1.2.0")
AlertDialog(
modifier = Modifier.padding(vertical = 45.dp),
AlertDialog(modifier = Modifier.padding(vertical = 45.dp),
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
@ -247,29 +167,21 @@ fun UnsupportedDialog(
}
},
title = { Text(stringResource(R.string.unsupported_app)) },
text = { Text(stringResource(R.string.app_not_supported, appVersion, supportedVersions.joinToString(", "))) }
)
text = { Text(stringResource(R.string.app_not_supported, appVersion, supportedVersions.joinToString(", "))) })
}
@Composable
fun OptionsDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit
onDismissRequest: () -> Unit, onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
Button(onClick = {
onConfirm()
onDismissRequest()
}
) {
Text(stringResource(R.string.apply))
}
},
title = { Text(stringResource(R.string.options)) },
text = {
Text("You really thought these would exist?")
AlertDialog(onDismissRequest = onDismissRequest, confirmButton = {
Button(onClick = {
onConfirm()
onDismissRequest()
}) {
Text(stringResource(R.string.apply))
}
)
}, title = { Text(stringResource(R.string.options)) }, text = {
Text("You really thought these would exist?")
})
}

View File

@ -0,0 +1,18 @@
package app.revanced.manager.compose.ui.viewmodel
import android.app.Application
import android.net.Uri
import androidx.lifecycle.ViewModel
import app.revanced.manager.compose.util.PM
import java.io.File
import java.nio.file.Files
class AppSelectorViewModel(private val app: Application) : ViewModel() {
fun loadSelectedFile(uri: Uri) =
app.contentResolver.openInputStream(uri)!!.use { stream ->
File(app.cacheDir, "input.apk").also {
if (it.exists()) it.delete()
Files.copy(stream, it.toPath())
}.let { PM.getApkInfo(it, app) }
}
}

View File

@ -0,0 +1,109 @@
package app.revanced.manager.compose.ui.viewmodel
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
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.patcher.worker.PatcherWorker
import app.revanced.manager.compose.patcher.worker.PatcherProgressManager
import app.revanced.manager.compose.patcher.worker.StepGroup
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 kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
class InstallerScreenViewModel(
input: PackageInfo,
selectedPatches: List<String>,
private val app: Application
) : ViewModel() {
var stepGroups by mutableStateOf<List<StepGroup>>(PatcherProgressManager.generateGroupsList(app, selectedPatches))
private set
private val workManager = WorkManager.getInstance(app)
// TODO: handle app installation as a step.
var installStatus by mutableStateOf<Boolean?>(null)
var pmStatus by mutableStateOf(-999)
var extra by mutableStateOf("")
private val outputFile = File(app.cacheDir, "output.apk")
private val patcherWorker =
OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).setInputData(
workDataOf(
PatcherWorker.ARGS_KEY to
Json.Default.encodeToString(
PatcherWorker.Args(
input.apk.path,
outputFile.path,
selectedPatches,
input.packageName,
input.packageName,
)
)
)
).build()
private val liveData = workManager.getWorkInfoByIdLiveData(patcherWorker.id) // get LiveData
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
else -> null
}?.let { PatcherProgressManager.groupsFromWorkData(it) }?.let { stepGroups = it }
}
private val installBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
InstallService.APP_INSTALL_ACTION -> {
pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999)
extra = intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
postInstallStatus()
}
UninstallService.APP_UNINSTALL_ACTION -> {
}
}
}
}
init {
workManager.enqueueUniqueWork("patching", ExistingWorkPolicy.KEEP, patcherWorker)
liveData.observeForever(observer)
app.registerReceiver(installBroadcastReceiver, IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
})
}
fun installApk(apk: List<File>) {
PM.installApp(apk, app)
}
fun postInstallStatus() {
installStatus = pmStatus == PackageInstaller.STATUS_SUCCESS
}
override fun onCleared() {
super.onCleared()
liveData.removeObserver(observer)
app.unregisterReceiver(installBroadcastReceiver)
workManager.cancelWorkById(patcherWorker.id)
// logs.clear()
}
}

View File

@ -1,85 +1,61 @@
package app.revanced.manager.compose.ui.viewmodel
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import app.revanced.manager.compose.patcher.data.repository.PatchesRepository
import app.revanced.manager.compose.patcher.patch.PatchInfo
import app.revanced.manager.compose.util.PackageInfo
import kotlinx.coroutines.flow.map
class PatchesSelectorViewModel: ViewModel() {
private val patchesList = listOf(
Patch("amogus-patch", "adds amogus to all apps, mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus mogus ",
options = listOf(
Option(
"amogus"
)
),
isSupported = true
),
Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = true
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(
Option(
"amogus"
)
),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),Patch("microg-support", "makes microg work",
options = listOf(),
isSupported = false
),
class PatchesSelectorViewModel(packageInfo: PackageInfo, patchesRepository: PatchesRepository) :
ViewModel() {
val bundlesFlow = patchesRepository.getPatchInformation().map { patches ->
val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>()
).let { it + it + it + it + it }
patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach {
val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported
private val patchesLists = patchesList.groupBy { if (it.isSupported) "supported" else "unsupported" }
targetList.add(it)
}
val bundles = listOf(
Bundle(
name = "offical",
patches = patchesLists
),
Bundle(
name = "extended",
patches = patchesLists
),
Bundle(
name = "balls",
patches = patchesLists
),
)
listOf(
Bundle(
name = "official",
supported, unsupported
)
)
}
var selectedPatches = mutableStateListOf<Patch>()
val selectedPatches = mutableStateListOf<String>()
fun isSelected(patch: PatchInfo) = selectedPatches.contains(patch.name)
fun togglePatch(patch: PatchInfo) {
val name = patch.name
if (isSelected(patch)) selectedPatches.remove(name) else selectedPatches.add(patch.name)
}
data class Bundle(
val name: String,
val patches: Map<String, List<Patch>>
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>
)
data class Patch(
val name: String,
val description: String,
val options: List<Option>,
val isSupported: Boolean
)
var showOptionsDialog by mutableStateOf(false)
private set
var showUnsupportedDialog by mutableStateOf(false)
private set
data class Option(
val name: String
)
fun dismissDialogs() {
showOptionsDialog = false
showUnsupportedDialog = false
}
fun openOptionsDialog() {
showOptionsDialog = true
}
fun openUnsupportedDialog() {
showUnsupportedDialog = true
}
}

View File

@ -6,120 +6,69 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PackageInfoFlags
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Parcelable
import androidx.compose.runtime.mutableStateListOf
import app.revanced.manager.compose.service.InstallService
import app.revanced.manager.compose.service.UninstallService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import java.io.File
private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
@Parcelize
data class PackageInfo(val packageName: String, val version: String, val apk: File) : Parcelable {
constructor(appInfo: PM.AppInfo) : this(appInfo.packageName, appInfo.versionName, appInfo.apk)
}
@SuppressLint("QueryPermissionsNeeded")
@Suppress("DEPRECATION")
object PM {
val testList = mapOf(
"com.google.android.youtube" to 59,
"com.android.vending" to 34,
"com.backdrops.wallpapers" to 2,
"com.termux" to 2,
"com.notinstalled.app" to 1,
"com.2notinstalled.app" to 1,
"org.adaway" to 5,
"com.activitymanager" to 1,
"com.guoshi.httpcanary" to 1,
"org.lsposed.lspatch" to 1,
"app.revanced.manager.flutter" to 100,
"com.reddit.frontpage" to 20
)
val appList = mutableStateListOf<AppInfo>()
val supportedAppList = mutableStateListOf<AppInfo>()
suspend fun loadApps(context: Context) {
val packageManager = context.packageManager
testList.keys.map {
try {
val applicationInfo = packageManager.getApplicationInfo(it, 0)
AppInfo(
it,
applicationInfo.loadLabel(packageManager).toString(),
applicationInfo.loadIcon(packageManager),
)
} catch (e: PackageManager.NameNotFoundException) {
AppInfo(
it,
"Not installed"
)
}
}.let { list ->
list.sortedWith(
compareByDescending<AppInfo> {
testList[it.packageName]
}.thenBy { it.label }.thenBy { it.packageName }
)
}.also {
withContext(Dispatchers.Main) { supportedAppList.addAll(it) }
}
val localAppList = mutableListOf<AppInfo>()
packageManager.getInstalledApplications(PackageManager.GET_META_DATA).map {
AppInfo(
it.packageName,
"0.69.420",
it.loadLabel(packageManager).toString(),
it.loadIcon(packageManager)
it.loadIcon(packageManager),
File("h")
)
}.also { localAppList.addAll(it) }
testList.keys.mapNotNull { packageName ->
if (!localAppList.any { packageName == it.packageName }) {
AppInfo(
packageName,
"Not installed"
)
} else {
null
}
}.also { localAppList.addAll(it) }
localAppList.sortWith(
compareByDescending<AppInfo> {
testList[it.packageName]
}.thenBy { it.label }.thenBy { it.packageName }
).also {
withContext(Dispatchers.Main) { appList.addAll(localAppList) }
}
}.also { localAppList.addAll(it) }.also { supportedAppList.addAll(it) }
}
@Parcelize
data class AppInfo(
val packageName: String,
val versionName: String,
val label: String,
val icon: @RawValue Drawable? = null
val icon: @RawValue Drawable? = null,
val apk: File,
) : Parcelable
fun installApp(apk: File, context: Context) {
fun installApp(apks: List<File>, context: Context) {
val packageInstaller = context.packageManager.packageInstaller
val session =
packageInstaller.openSession(packageInstaller.createSession(sessionParams))
session.writeApk(apk)
session.commit(context.installIntentSender)
session.close()
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
apks.forEach { apk -> session.writeApk(apk) }
session.commit(context.installIntentSender)
}
}
fun uninstallPackage(pkg: String, context: Context) {
val packageInstaller = context.packageManager.packageInstaller
packageInstaller.uninstall(pkg, context.uninstallIntentSender)
}
fun getApkInfo(apk: File, context: Context) = context.packageManager.getPackageArchiveInfo(apk.path, 0)!!.let { PackageInfo(it.packageName, it.versionName, apk) }
}
private fun PackageInstaller.Session.writeApk(apk: File) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -58,5 +58,15 @@
<string name="no_sources_set">No sources set</string>
<string name="no_patched_apps_found">No patched apps found</string>
<string name="unsupported_app">Unsupported app</string>
<string name="unsupported_patches">Unsupported patches</string>
<string name="app_not_supported">Some of the patches do not support this app version (%s). The patches only support the following versions: %s.</string>
<string name="installer">Installer</string>
<string name="patcher_step_group_prepare">Preparation</string>
<string name="patcher_step_unpack">Unpack Apk</string>
<string name="patcher_step_integrations">Merge Integrations</string>
<string name="patcher_step_group_patching">Patching</string>
<string name="patcher_step_group_saving">Saving</string>
<string name="patcher_step_write_patched">Write patched Apk</string>
</resources>