diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..fe8a779
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+Vendetta Manager
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b589d56
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/discord.xml b/.idea/discord.xml
new file mode 100644
index 0000000..30bab2a
--- /dev/null
+++ b/.idea/discord.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 0000000..644debd
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..e1eea1d
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..f5db383
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..46b502a
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,165 @@
+import java.io.ByteArrayOutputStream
+
+plugins {
+ id("com.android.application")
+ kotlin("android")
+}
+
+android {
+ namespace = "dev.beefers.vendetta.manager"
+ compileSdk = 33
+
+ defaultConfig {
+ applicationId = "dev.beefers.vendetta.manager"
+ minSdk = 24
+ targetSdk = 33
+ versionCode = 1000
+ versionName = "1.0.0"
+
+ buildConfigField("String", "GIT_BRANCH", "\"${getCurrentBranch()}\"")
+ buildConfigField("String", "GIT_COMMIT", "\"${getLatestCommit()}\"")
+ buildConfigField("boolean", "GIT_LOCAL_COMMITS", "${hasLocalCommits()}")
+ buildConfigField("boolean", "GIT_LOCAL_CHANGES", "${hasLocalChanges()}")
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ named("release") {
+ isMinifyEnabled = false
+ setProguardFiles(listOf(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"))
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ freeCompilerArgs += listOf(
+ "-Xcontext-receivers",
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${buildDir.resolve("report").absolutePath}",
+ )
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.3.2"
+ }
+ androidComponents {
+ onVariants(selector().withBuildType("release")) {
+ it.packaging.resources.excludes.apply {
+ // Debug metadata
+ add("/**/*.version")
+ add("/kotlin-tooling-metadata.json")
+ // Kotlin debugging (https://github.com/Kotlin/kotlinx.coroutines/issues/2274)
+ add("/DebugProbesKt.bin")
+ }
+ }
+ }
+
+ packagingOptions {
+ resources {
+ // Reflection symbol list (https://stackoverflow.com/a/41073782/13964629)
+ excludes += "/**/*.kotlin_builtins"
+ }
+ }
+ configurations {
+ all {
+ exclude(module = "listenablefuture")
+ }
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.9.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1")
+
+ implementation(platform("androidx.compose:compose-bom:2022.10.00"))
+ implementation("androidx.activity:activity-compose:1.5.1")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+
+ val koinVersion = "3.2.0"
+
+ implementation("io.insert-koin:koin-core:$koinVersion")
+ implementation("io.insert-koin:koin-android:$koinVersion")
+ implementation("io.insert-koin:koin-androidx-compose:$koinVersion")
+
+ val accompanistVersion = "0.29.1-alpha"
+
+ implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
+ implementation("com.google.accompanist:accompanist-pager:$accompanistVersion")
+
+ val voyagerVersion = "1.0.0-rc03"
+
+ implementation("cafe.adriel.voyager:voyager-navigator:$voyagerVersion")
+ implementation("cafe.adriel.voyager:voyager-tab-navigator:$voyagerVersion")
+ implementation("cafe.adriel.voyager:voyager-transitions:$voyagerVersion")
+ implementation("cafe.adriel.voyager:voyager-koin:$voyagerVersion")
+
+ val ktorVersion = "2.1.1"
+
+ implementation("io.ktor:ktor-client-core:$ktorVersion")
+ implementation("io.ktor:ktor-client-cio:$ktorVersion")
+ implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
+ implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
+ implementation("io.ktor:ktor-client-logging:$ktorVersion")
+
+ implementation("io.github.diamondminer88:zip-android:2.1.0@aar")
+// implementation("com.android.tools.build:apksig:7.4.0-beta04")
+ implementation(files("libs/lspatch.jar"))
+
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
+
+fun getCurrentBranch(): String? =
+ exec("git", "symbolic-ref", "--short", "HEAD")
+
+fun getLatestCommit(): String? =
+ exec("git", "rev-parse", "--short", "HEAD")
+
+fun hasLocalCommits(): Boolean {
+ val branch = getCurrentBranch() ?: return false
+ return exec("git", "log", "origin/$branch..HEAD")?.isNotEmpty() ?: false
+}
+
+fun hasLocalChanges(): Boolean =
+ exec("git", "status", "-s")?.isNotEmpty() ?: false
+
+fun exec(vararg command: String): String? {
+ return try {
+ val stdout = ByteArrayOutputStream()
+ val errout = ByteArrayOutputStream()
+
+ exec {
+ commandLine = command.toList()
+ standardOutput = stdout
+ errorOutput = errout
+ isIgnoreExitValue = true
+ }
+
+ if(errout.size() > 0)
+ throw Error(errout.toString(Charsets.UTF_8))
+
+ stdout.toString(Charsets.UTF_8).trim()
+ } catch (e: Throwable) {
+ e.printStackTrace()
+ null
+ }
+}
\ No newline at end of file
diff --git a/app/libs/lspatch.jar b/app/libs/lspatch.jar
new file mode 100644
index 0000000..a3f4c2d
Binary files /dev/null and b/app/libs/lspatch.jar differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..a92338a
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,43 @@
+## Keep `Companion` object fields of serializable classes.
+## This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
+#-if @kotlinx.serialization.Serializable class **
+#-keepclassmembers class <1> {
+# static <1>$Companion Companion;
+#}
+#
+## Keep `serializer()` on companion objects (both default and named) of serializable classes.
+#-if @kotlinx.serialization.Serializable class ** {
+# static **$* *;
+#}
+#-keepclassmembers class <2>$<3> {
+# kotlinx.serialization.KSerializer serializer(...);
+#}
+#
+## Keep `INSTANCE.serializer()` of serializable objects.
+#-if @kotlinx.serialization.Serializable class ** {
+# public static ** INSTANCE;
+#}
+#-keepclassmembers class <1> {
+# public static <1> INSTANCE;
+# kotlinx.serialization.KSerializer serializer(...);
+#}
+#
+## @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
+#-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
+#
+#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
+# static <1>$$serializer INSTANCE;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+-keepattributes SourceFile,LineNumberTable
+
+# Repackage classes into the top-level.
+-repackageclasses
+
+# Amount of optimization iterations, taken from an SO post
+-optimizationpasses 5
+
+# Broaden access modifiers to increase results during optimization
+-allowaccessmodification
\ No newline at end of file
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
new file mode 100644
index 0000000..ba9b13f
--- /dev/null
+++ b/app/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "dev.beefers.vendetta.manager",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 1,
+ "versionName": "1.0.0",
+ "outputFile": "app-release.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/dev/beefers/vendetta/manager/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/beefers/vendetta/manager/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..cc84571
--- /dev/null
+++ b/app/src/androidTest/java/dev/beefers/vendetta/manager/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package dev.beefers.vendetta.manager
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("dev.beefers.vendetta.manager", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..33988b5
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/icons/ic_logo_foreground.png b/app/src/main/assets/icons/ic_logo_foreground.png
new file mode 100644
index 0000000..c37511f
Binary files /dev/null and b/app/src/main/assets/icons/ic_logo_foreground.png differ
diff --git a/app/src/main/assets/icons/ic_logo_round.png b/app/src/main/assets/icons/ic_logo_round.png
new file mode 100644
index 0000000..fc61d2e
Binary files /dev/null and b/app/src/main/assets/icons/ic_logo_round.png differ
diff --git a/app/src/main/assets/icons/ic_logo_square.png b/app/src/main/assets/icons/ic_logo_square.png
new file mode 100644
index 0000000..0ad330c
Binary files /dev/null and b/app/src/main/assets/icons/ic_logo_square.png differ
diff --git a/app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so b/app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so
new file mode 100644
index 0000000..d3b2b2e
Binary files /dev/null and b/app/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so differ
diff --git a/app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so b/app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so
new file mode 100644
index 0000000..136c2cf
Binary files /dev/null and b/app/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so differ
diff --git a/app/src/main/assets/lspatch/so/x86/liblspatch.so b/app/src/main/assets/lspatch/so/x86/liblspatch.so
new file mode 100644
index 0000000..fbefdf5
Binary files /dev/null and b/app/src/main/assets/lspatch/so/x86/liblspatch.so differ
diff --git a/app/src/main/assets/lspatch/so/x86_64/liblspatch.so b/app/src/main/assets/lspatch/so/x86_64/liblspatch.so
new file mode 100644
index 0000000..b08ac6a
Binary files /dev/null and b/app/src/main/assets/lspatch/so/x86_64/liblspatch.so differ
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ManagerApplication.kt b/app/src/main/java/dev/beefers/vendetta/manager/ManagerApplication.kt
new file mode 100644
index 0000000..ee10f9e
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ManagerApplication.kt
@@ -0,0 +1,26 @@
+package dev.beefers.vendetta.manager
+
+import android.app.Application
+import dev.beefers.vendetta.manager.di.httpModule
+import dev.beefers.vendetta.manager.di.managerModule
+import dev.beefers.vendetta.manager.di.repositoryModule
+import dev.beefers.vendetta.manager.di.viewModelModule
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+
+class ManagerApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ startKoin {
+ androidContext(this@ManagerApplication)
+ modules(
+ httpModule,
+ managerModule,
+ viewModelModule,
+ repositoryModule
+ )
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/di/HttpModule.kt b/app/src/main/java/dev/beefers/vendetta/manager/di/HttpModule.kt
new file mode 100644
index 0000000..20d40dd
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/di/HttpModule.kt
@@ -0,0 +1,31 @@
+package dev.beefers.vendetta.manager.di
+
+import dev.beefers.vendetta.manager.network.service.GithubService
+import dev.beefers.vendetta.manager.network.service.HttpService
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val httpModule = module {
+
+ fun provideJson() = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ }
+
+ fun provideHttpClient(json: Json) = HttpClient(CIO) {
+ install(ContentNegotiation) {
+ json(json)
+ }
+ }
+
+ singleOf(::provideJson)
+ singleOf(::provideHttpClient)
+ singleOf(::HttpService)
+ singleOf(::GithubService)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/di/ManagerModule.kt b/app/src/main/java/dev/beefers/vendetta/manager/di/ManagerModule.kt
new file mode 100644
index 0000000..299188a
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/di/ManagerModule.kt
@@ -0,0 +1,11 @@
+package dev.beefers.vendetta.manager.di
+
+import dev.beefers.vendetta.manager.domain.manager.DownloadManager
+import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val managerModule = module {
+ singleOf(::DownloadManager)
+ singleOf(::PreferenceManager)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/di/RepositoryModule.kt b/app/src/main/java/dev/beefers/vendetta/manager/di/RepositoryModule.kt
new file mode 100644
index 0000000..5295dc9
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/di/RepositoryModule.kt
@@ -0,0 +1,9 @@
+package dev.beefers.vendetta.manager.di
+
+import dev.beefers.vendetta.manager.domain.repository.GithubRepository
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val repositoryModule = module {
+ singleOf(::GithubRepository)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/di/ViewModelModule.kt b/app/src/main/java/dev/beefers/vendetta/manager/di/ViewModelModule.kt
new file mode 100644
index 0000000..87fa0c0
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/di/ViewModelModule.kt
@@ -0,0 +1,13 @@
+package dev.beefers.vendetta.manager.di
+
+import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel
+import dev.beefers.vendetta.manager.ui.viewmodel.main.MainViewModel
+import dev.beefers.vendetta.manager.ui.viewmodel.settings.SettingsViewModel
+import org.koin.core.module.dsl.factoryOf
+import org.koin.dsl.module
+
+val viewModelModule = module {
+ factoryOf(::InstallerViewModel)
+ factoryOf(::SettingsViewModel)
+ factoryOf(::MainViewModel)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt
new file mode 100644
index 0000000..2a720b0
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt
@@ -0,0 +1,134 @@
+package dev.beefers.vendetta.manager.domain.manager
+
+import android.app.DownloadManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+import dev.beefers.vendetta.manager.BuildConfig
+import java.io.File
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+class DownloadManager(
+ private val context: Context
+) {
+ private val downloadManager = context.getSystemService()!!
+
+ // Discord APK downloading
+ suspend fun downloadDiscordApk(version: String, out: File): File =
+ download("$BACKEND_HOST/download/discord?v=$version", out)
+
+ suspend fun downloadSplit(version: String, split: String, out: File): File =
+ download("$BACKEND_HOST/download/discord?v=$version&split=$split", out)
+
+ suspend fun downloadVendetta(out: File) =
+ download(
+ "https://github.com/vendetta-mod/VendettaXposed/releases/latest/download/app-release.apk",
+ out
+ )
+
+ suspend fun downloadUpdate(out: File) =
+ download(
+ "https://github.com/vendetta-mod/VendettaManager/releases/latest/download/Manager.apk",
+ out
+ )
+
+ suspend fun download(url: String, out: File): File {
+ out.parentFile?.mkdirs()
+
+ return suspendCoroutine { continuation ->
+ val receiver = object : BroadcastReceiver() {
+ var downloadId = 0L
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
+
+ if (downloadId != id) return
+
+ val (status, reason) = DownloadManager.Query().run {
+ setFilterById(downloadId)
+
+ val cursor = downloadManager.query(this)
+ .apply { moveToFirst() }
+
+ // Notification's "cancel" was clicked
+ if (cursor.count == 0) {
+ -1 to -1
+ } else {
+ val status =
+ cursor.run { getInt(getColumnIndex(DownloadManager.COLUMN_STATUS)) }
+ val reason =
+ cursor.run { getInt(getColumnIndex(DownloadManager.COLUMN_REASON)) }
+ status to reason
+ }
+ }
+
+ when (status) {
+ // Cancelled
+ -1 -> {
+ context.unregisterReceiver(this)
+ continuation.resumeWithException(Error("Download was cancelled manually"))
+ }
+
+ DownloadManager.STATUS_SUCCESSFUL -> {
+ context.unregisterReceiver(this)
+ continuation.resume(out)
+ }
+
+ DownloadManager.STATUS_FAILED -> {
+ context.unregisterReceiver(this)
+ continuation.resumeWithException(
+ Error(
+ "Failed to download $url because of: ${
+ reasonToString(
+ reason
+ )
+ }"
+ )
+ )
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ context.registerReceiver(
+ receiver,
+ IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+ )
+
+ receiver.downloadId = DownloadManager.Request(url.toUri()).run {
+ setTitle("Vendetta Manager: Downloading ${out.name}")
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
+ setDestinationUri(out.toUri())
+ addRequestHeader("User-Agent", "Vendetta Manager/${BuildConfig.VERSION_NAME}")
+ downloadManager.enqueue(this)
+ }
+ }
+ }
+
+ private fun reasonToString(int: Int): String {
+ return when (int) {
+ DownloadManager.ERROR_UNKNOWN -> "Unknown Error"
+ DownloadManager.ERROR_FILE_ERROR -> "File Error"
+ DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP Code"
+ DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP Data Error"
+ DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
+ DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space"
+ DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Device not found"
+ DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
+ DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists"
+ else -> "Unknown error"
+ }
+ }
+
+ companion object {
+ private const val BACKEND_HOST = "https://aliucord.com/"
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/PreferenceManager.kt b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/PreferenceManager.kt
new file mode 100644
index 0000000..002d00f
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/PreferenceManager.kt
@@ -0,0 +1,34 @@
+package dev.beefers.vendetta.manager.domain.manager
+
+import android.content.Context
+import android.os.Build
+import androidx.annotation.StringRes
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.domain.manager.base.BasePreferenceManager
+
+class PreferenceManager(private val context: Context) :
+ BasePreferenceManager(context.getSharedPreferences("prefs", Context.MODE_PRIVATE)) {
+
+ var packageName by stringPreference("package_name", "dev.beefers.vendetta")
+
+ var appName by stringPreference("app_name", "Vendetta")
+
+ var discordVersion by stringPreference("discord_version", "")
+
+ var patchIcon by booleanPreference("patch_icon", true)
+
+ var debuggable by booleanPreference("debuggable", false)
+
+ var monet by booleanPreference("monet", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+
+ var isDeveloper by booleanPreference("is_developer", true)
+
+ var theme by enumPreference("theme", Theme.SYSTEM)
+
+}
+
+enum class Theme(@StringRes val labelRes: Int) {
+ SYSTEM(R.string.theme_system),
+ LIGHT(R.string.theme_light),
+ DARK(R.string.theme_dark)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/base/BasePreferenceManager.kt b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/base/BasePreferenceManager.kt
new file mode 100644
index 0000000..89c3ef5
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/base/BasePreferenceManager.kt
@@ -0,0 +1,115 @@
+package dev.beefers.vendetta.manager.domain.manager.base
+
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
+import androidx.core.content.edit
+import kotlin.reflect.KProperty
+
+abstract class BasePreferenceManager(
+ private val prefs: SharedPreferences
+) {
+ protected fun getString(key: String, defaultValue: String?) =
+ prefs.getString(key, defaultValue)!!
+
+ private fun getBoolean(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue)
+ private fun getInt(key: String, defaultValue: Int) = prefs.getInt(key, defaultValue)
+ private fun getFloat(key: String, defaultValue: Float) = prefs.getFloat(key, defaultValue)
+ private fun getColor(key: String, defaultValue: Color): Color {
+ val c = prefs.getString(key, null)
+ return if (c == null) defaultValue else Color(c.toULong())
+ }
+
+ protected inline fun > getEnum(key: String, defaultValue: E) =
+ enumValueOf(getString(key, defaultValue.name))
+
+ protected fun putString(key: String, value: String?) = prefs.edit { putString(key, value) }
+ private fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) }
+ private fun putInt(key: String, value: Int) = prefs.edit { putInt(key, value) }
+ private fun putFloat(key: String, value: Float) = prefs.edit { putFloat(key, value) }
+ private fun putColor(key: String, value: Color) =
+ prefs.edit { putString(key, value.value.toString()) }
+
+ protected inline fun > putEnum(key: String, value: E) =
+ putString(key, value.name)
+
+ protected class Preference(
+ private val key: String,
+ defaultValue: T,
+ getter: (key: String, defaultValue: T) -> T,
+ private val setter: (key: String, newValue: T) -> Unit
+ ) {
+ @Suppress("RedundantSetter")
+ var value by mutableStateOf(getter(key, defaultValue))
+ private set
+
+ operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
+ operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
+ value = newValue
+ setter(key, newValue)
+ }
+ }
+
+ protected fun stringPreference(
+ key: String,
+ defaultValue: String = ""
+ ) = Preference(
+ key = key,
+ defaultValue = defaultValue,
+ getter = ::getString,
+ setter = ::putString
+ )
+
+ protected fun booleanPreference(
+ key: String,
+ defaultValue: Boolean
+ ) = Preference(
+ key = key,
+ defaultValue = defaultValue,
+ getter = ::getBoolean,
+ setter = ::putBoolean
+ )
+
+ protected fun intPreference(
+ key: String,
+ defaultValue: Int
+ ) = Preference(
+ key = key,
+ defaultValue = defaultValue,
+ getter = ::getInt,
+ setter = ::putInt
+ )
+
+ protected fun floatPreference(
+ key: String,
+ defaultValue: Float
+ ) = Preference(
+ key = key,
+ defaultValue = defaultValue,
+ getter = ::getFloat,
+ setter = ::putFloat
+ )
+
+ protected fun colorPreference(
+ key: String,
+ defaultValue: Color
+ ) = Preference(
+ key = key,
+ defaultValue = defaultValue,
+ getter = ::getColor,
+ setter = ::putColor
+ )
+
+
+ protected inline fun > enumPreference(
+ key: String,
+ defaultValue: E
+ ) = Preference(
+ key = key,
+ defaultValue = defaultValue,
+ getter = ::getEnum,
+ setter = ::putEnum
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/domain/repository/GithubRepository.kt b/app/src/main/java/dev/beefers/vendetta/manager/domain/repository/GithubRepository.kt
new file mode 100644
index 0000000..3a0c98e
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/domain/repository/GithubRepository.kt
@@ -0,0 +1,11 @@
+package dev.beefers.vendetta.manager.domain.repository
+
+import dev.beefers.vendetta.manager.network.service.GithubService
+
+class GithubRepository(
+ private val service: GithubService
+) {
+
+ suspend fun getLatestRelease() = service.getLatestRelease()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/service/InstallService.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/service/InstallService.kt
new file mode 100644
index 0000000..f27b3ca
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/service/InstallService.kt
@@ -0,0 +1,34 @@
+package dev.beefers.vendetta.manager.installer.service
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.IBinder
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.utils.showToast
+
+class InstallService : Service() {
+ override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ when (val statusCode = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ @Suppress("DEPRECATION") // No.
+ val confirmationIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT)!!
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ startActivity(confirmationIntent)
+ }
+
+ PackageInstaller.STATUS_SUCCESS -> showToast(R.string.installer_success)
+ PackageInstaller.STATUS_FAILURE_ABORTED -> showToast(R.string.installer_aborted)
+
+ else -> {
+ showToast(R.string.installer_failed, statusCode)
+ }
+ }
+
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent): IBinder? = null
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/util/ManifestPatcher.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/ManifestPatcher.kt
new file mode 100644
index 0000000..5a12448
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/ManifestPatcher.kt
@@ -0,0 +1,236 @@
+package dev.beefers.vendetta.manager.installer.util
+
+import android.Manifest
+import android.os.Build
+import pxb.android.axml.AxmlReader
+import pxb.android.axml.AxmlVisitor
+import pxb.android.axml.AxmlWriter
+import pxb.android.axml.NodeVisitor
+
+object ManifestPatcher {
+ private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
+ private const val USES_CLEARTEXT_TRAFFIC = "usesCleartextTraffic"
+ private const val DEBUGGABLE = "debuggable"
+ private const val REQUEST_LEGACY_EXTERNAL_STORAGE = "requestLegacyExternalStorage"
+ private const val NETWORK_SECURITY_CONFIG = "networkSecurityConfig"
+ private const val LABEL = "label"
+ private const val PACKAGE = "package"
+ private const val COMPILE_SDK_VERSION = "compileSdkVersion"
+ private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename"
+
+ fun patchManifest(
+ manifestBytes: ByteArray,
+ packageName: String,
+ appName: String,
+ debuggable: Boolean
+ ): ByteArray {
+ val reader = AxmlReader(manifestBytes)
+ val writer = AxmlWriter()
+
+ reader.accept(object : AxmlVisitor(writer) {
+ override fun child(ns: String?, name: String?) =
+ object : ReplaceAttrsVisitor(
+ super.child(ns, name),
+ mapOf(
+ PACKAGE to packageName,
+ COMPILE_SDK_VERSION to 23,
+ COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
+ )
+ ) {
+ private var addExternalStoragePerm = false
+
+ override fun child(ns: String?, name: String): NodeVisitor {
+ val nv = super.child(ns, name)
+
+ // Add MANAGE_EXTERNAL_STORAGE when necessary
+ if (addExternalStoragePerm) {
+ super
+ .child(null, "uses-permission")
+ .attr(
+ ANDROID_NAMESPACE,
+ "name",
+ android.R.attr.name,
+ TYPE_STRING,
+ Manifest.permission.MANAGE_EXTERNAL_STORAGE
+ )
+ addExternalStoragePerm = false
+ }
+
+ return when (name) {
+ "uses-permission" -> object : NodeVisitor(nv) {
+ override fun attr(
+ ns: String?,
+ name: String?,
+ resourceId: Int,
+ type: Int,
+ value: Any?
+ ) {
+ if (name != "maxSdkVersion") {
+ super.attr(ns, name, resourceId, type, value)
+ }
+
+ // Set the add external storage permission to be added after WRITE_EXTERNAL_STORAGE (which is after read)
+ if (name == "name" && value == Manifest.permission.READ_EXTERNAL_STORAGE) {
+ addExternalStoragePerm = true
+ }
+ }
+ }
+
+ "uses-sdk" -> object : NodeVisitor(nv) {
+ override fun attr(
+ ns: String?,
+ name: String?,
+ resourceId: Int,
+ type: Int,
+ value: Any?
+ ) {
+ if (name == "targetSdkVersion") {
+ val version = if (Build.VERSION.SDK_INT >= 31) 30 else 28
+ super.attr(ns, name, resourceId, type, version)
+ } else {
+ super.attr(ns, name, resourceId, type, value)
+ }
+ }
+ }
+
+ "application" -> object : ReplaceAttrsVisitor(
+ nv,
+ mapOf(
+ LABEL to appName,
+ DEBUGGABLE to debuggable,
+ USES_CLEARTEXT_TRAFFIC to true,
+ REQUEST_LEGACY_EXTERNAL_STORAGE to true
+ )
+ ) {
+ private var addDebuggable = debuggable
+ private var addLegacyStorage = true
+ private var addUseClearTextTraffic = true
+
+ override fun attr(
+ ns: String?,
+ name: String,
+ resourceId: Int,
+ type: Int,
+ value: Any?
+ ) {
+ if (name == NETWORK_SECURITY_CONFIG) return
+ super.attr(ns, name, resourceId, type, value)
+ if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage =
+ false
+ if (name == DEBUGGABLE) addDebuggable = false
+ if (name == USES_CLEARTEXT_TRAFFIC) addUseClearTextTraffic =
+ false
+ }
+
+ override fun child(ns: String?, name: String): NodeVisitor {
+ val visitor = super.child(ns, name)
+
+ return when (name) {
+ "activity" -> ReplaceAttrsVisitor(
+ visitor,
+ mapOf("label" to appName)
+ )
+
+ "provider" -> object : NodeVisitor(visitor) {
+ override fun attr(
+ ns: String?,
+ name: String,
+ resourceId: Int,
+ type: Int,
+ value: Any?
+ ) {
+ super.attr(
+ ns,
+ name,
+ resourceId,
+ type,
+ if (name == "authorities") {
+ (value as String).replace(
+ "com.discord",
+ packageName
+ )
+ } else {
+ value
+ }
+ )
+ }
+ }
+
+ else -> visitor
+ }
+ }
+
+ override fun end() {
+ if (addLegacyStorage) super.attr(
+ ANDROID_NAMESPACE,
+ REQUEST_LEGACY_EXTERNAL_STORAGE,
+ -1,
+ TYPE_INT_BOOLEAN,
+ 1
+ )
+ if (addDebuggable) super.attr(
+ ANDROID_NAMESPACE,
+ DEBUGGABLE,
+ -1,
+ TYPE_INT_BOOLEAN,
+ 1
+ )
+ if (addUseClearTextTraffic) super.attr(
+ ANDROID_NAMESPACE,
+ USES_CLEARTEXT_TRAFFIC,
+ -1,
+ TYPE_INT_BOOLEAN,
+ 1
+ )
+ super.end()
+ }
+ }
+
+ else -> nv
+ }
+ }
+ }
+ })
+
+ return writer.toByteArray()
+ }
+
+ fun renamePackage(
+ manifestBytes: ByteArray,
+ packageName: String
+ ): ByteArray {
+ val reader = AxmlReader(manifestBytes)
+ val writer = AxmlWriter()
+
+ reader.accept(
+ object : AxmlVisitor(writer) {
+ override fun child(ns: String?, name: String?): ReplaceAttrsVisitor {
+ return ReplaceAttrsVisitor(
+ super.child(ns, name),
+ mapOf("package" to packageName)
+ )
+ }
+ }
+ )
+
+ return writer.toByteArray()
+ }
+
+ private open class ReplaceAttrsVisitor(
+ nv: NodeVisitor,
+ private val attrs: Map
+ ) : NodeVisitor(nv) {
+ override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
+ val replace = attrs.containsKey(name)
+ val newValue = attrs[name]
+
+ super.attr(
+ ns,
+ name,
+ resourceId,
+ if (newValue is String) TYPE_STRING else type,
+ if (replace) newValue else value
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/util/PackageInstaller.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/PackageInstaller.kt
new file mode 100644
index 0000000..7ada0d5
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/PackageInstaller.kt
@@ -0,0 +1,46 @@
+package dev.beefers.vendetta.manager.installer.util
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller.SessionParams
+import android.content.pm.PackageManager
+import android.os.Build
+import dev.beefers.vendetta.manager.installer.service.InstallService
+import java.io.File
+
+fun Context.installApks(silent: Boolean = false, vararg apks: File) {
+ val packageInstaller = packageManager.packageInstaller
+ val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
+ if (Build.VERSION.SDK_INT >= 31) {
+ setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
+
+ if (silent) {
+ setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
+ }
+ }
+ }
+
+ val sessionId = packageInstaller.createSession(params)
+ val session = packageInstaller.openSession(sessionId)
+
+ apks.forEach { apk ->
+ session.openWrite(apk.name, 0, apk.length()).use {
+ it.write(apk.readBytes())
+ session.fsync(it)
+ }
+ }
+
+ val callbackIntent = Intent(this, InstallService::class.java)
+
+ @SuppressLint("UnspecifiedImmutableFlag")
+ val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.getService(this, 0, callbackIntent, PendingIntent.FLAG_MUTABLE)
+ } else {
+ PendingIntent.getService(this, 0, callbackIntent, 0)
+ }
+
+ session.commit(contentIntent.intentSender)
+ session.close()
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/util/Patcher.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/Patcher.kt
new file mode 100644
index 0000000..9cc83a5
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/Patcher.kt
@@ -0,0 +1,64 @@
+package dev.beefers.vendetta.manager.installer.util
+
+import dev.beefers.vendetta.manager.network.utils.Signer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.lsposed.lspatch.share.PatchConfig
+import org.lsposed.patch.LSPatch
+import org.lsposed.patch.util.Logger
+import java.io.File
+
+object Patcher {
+
+ class Options(
+ private val config: PatchConfig,
+ private val outputDir: File,
+ private val apkPaths: List,
+ private val embeddedModules: List?
+ ) {
+
+ fun toStringArray(): Array {
+ return buildList {
+ addAll(apkPaths)
+ add("-o"); add(outputDir.absolutePath)
+ if (config.debuggable) add("-d")
+ add("-l"); add(config.sigBypassLevel.toString())
+ if (config.useManager) add("--manager")
+ if (config.overrideVersionCode) add("-r")
+ add("-v")
+ embeddedModules?.forEach {
+ add("-m"); add(it)
+ }
+ addAll(arrayOf("-k", Signer.keyStore.absolutePath, "password", "alias", "password"))
+ }.toTypedArray()
+ }
+
+ }
+
+ suspend fun patch(
+ logger: Logger,
+ outputDir: File,
+ apkPaths: List,
+ embeddedModules: List
+ ) {
+ withContext(Dispatchers.IO) {
+ LSPatch(
+ logger,
+ *apkPaths.toTypedArray(),
+ "-o",
+ outputDir.absolutePath,
+ "-l",
+ "2",
+ "-v",
+ "-m",
+ *embeddedModules.toTypedArray(),
+ "-k",
+ Signer.keyStore.absolutePath,
+ "password",
+ "alias",
+ "password"
+ ).doCommandLine()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/network/dto/Release.kt b/app/src/main/java/dev/beefers/vendetta/manager/network/dto/Release.kt
new file mode 100644
index 0000000..d13dd56
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/network/dto/Release.kt
@@ -0,0 +1,10 @@
+package dev.beefers.vendetta.manager.network.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Release(
+ @SerialName("tag_name") val versionCode: Int,
+ @SerialName("name") val versionName: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/network/service/GithubService.kt b/app/src/main/java/dev/beefers/vendetta/manager/network/service/GithubService.kt
new file mode 100644
index 0000000..13c37df
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/network/service/GithubService.kt
@@ -0,0 +1,18 @@
+package dev.beefers.vendetta.manager.network.service
+
+import dev.beefers.vendetta.manager.network.dto.Release
+import io.ktor.client.request.url
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class GithubService(
+ private val httpService: HttpService
+) {
+
+ suspend fun getLatestRelease() = withContext(Dispatchers.IO) {
+ httpService.request {
+ url("https://api.github.com/repos/vendetta-mod/VendettaManager/releases/latest")
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/network/service/HttpService.kt b/app/src/main/java/dev/beefers/vendetta/manager/network/service/HttpService.kt
new file mode 100644
index 0000000..97a897c
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/network/service/HttpService.kt
@@ -0,0 +1,49 @@
+package dev.beefers.vendetta.manager.network.service
+
+import dev.beefers.vendetta.manager.network.utils.ApiError
+import dev.beefers.vendetta.manager.network.utils.ApiFailure
+import dev.beefers.vendetta.manager.network.utils.ApiResponse
+import io.ktor.client.HttpClient
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.request
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.isSuccess
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+
+class HttpService(
+ val json: Json,
+ val http: HttpClient
+) {
+
+ suspend inline fun request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse {
+ var body: String? = null
+
+ val response = try {
+ val response = http.request(builder)
+
+ if (response.status.isSuccess()) {
+ body = response.bodyAsText()
+
+ if (T::class == String::class) {
+ return ApiResponse.Success(body as T)
+ }
+
+ ApiResponse.Success(json.decodeFromString(body))
+ } else {
+ body = try {
+ response.bodyAsText()
+ } catch (e: Throwable) {
+ null
+ }
+
+ ApiResponse.Error(ApiError(response.status, body))
+ }
+ } catch (e: Throwable) {
+ ApiResponse.Failure(ApiFailure(e, body))
+ }
+
+ return response
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/network/utils/ApiResponse.kt b/app/src/main/java/dev/beefers/vendetta/manager/network/utils/ApiResponse.kt
new file mode 100644
index 0000000..51f178b
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/network/utils/ApiResponse.kt
@@ -0,0 +1,26 @@
+package dev.beefers.vendetta.manager.network.utils
+
+import io.ktor.http.HttpStatusCode
+
+sealed interface ApiResponse {
+ data class Success(val data: D) : ApiResponse
+ data class Error(val error: ApiError) : ApiResponse
+ data class Failure(val error: ApiFailure) : ApiResponse
+}
+
+class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
+class ApiFailure(error: Throwable, body: String?) : Error(body, error)
+
+val ApiResponse.dataOrNull
+ get() = if (this is ApiResponse.Success) data else null
+
+val ApiResponse.dataOrThrow
+ get() = when (this) {
+ is ApiResponse.Success -> data
+ is ApiResponse.Error -> throw error
+ is ApiResponse.Failure -> throw error
+ }
+
+inline fun ApiResponse.ifSuccessful(block: (D) -> Unit) {
+ if (this is ApiResponse.Success) block(data)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/network/utils/Signer.kt b/app/src/main/java/dev/beefers/vendetta/manager/network/utils/Signer.kt
new file mode 100644
index 0000000..a5b1381
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/network/utils/Signer.kt
@@ -0,0 +1,122 @@
+package dev.beefers.vendetta.manager.network.utils
+
+import android.content.Context
+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.operator.jcajce.JcaContentSignerBuilder
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.io.File
+import java.math.BigInteger
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.PrivateKey
+import java.security.SecureRandom
+import java.security.cert.Certificate
+import java.security.cert.X509Certificate
+import java.util.Date
+import java.util.Locale
+
+object Signer : KoinComponent {
+ private val password = "password".toCharArray()
+ private val cacheDir = inject().value.cacheDir
+
+ val keyStore: File
+ get() {
+ lateinit var ks: File
+ cacheDir.resolve("ks.keystore").also {
+ if (!it.exists()) {
+ cacheDir.mkdir()
+ newKeystore(it)
+ }
+ ks = it
+ }
+ return ks
+ }
+
+ private val signerConfig: ApkSigner.SignerConfig by lazy {
+ val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
+
+ cacheDir.resolve("ks.keystore").also {
+ if (!it.exists()) {
+ cacheDir.mkdir()
+ newKeystore(it)
+ }
+ }.inputStream().use { stream ->
+ keyStore.load(stream, null)
+ }
+
+ val alias = keyStore.aliases().nextElement()
+ val certificate = keyStore.getCertificate(alias) as X509Certificate
+
+ ApkSigner.SignerConfig.Builder(
+ "Vendetta",
+ keyStore.getKey(alias, password) as PrivateKey,
+ listOf(certificate)
+ ).build()
+ }
+
+ private fun newKeystore(out: File?) {
+ val key = createKey()
+
+ with(KeyStore.getInstance(KeyStore.getDefaultType())) {
+ load(null, password)
+ setKeyEntry("alias", key.privateKey, password, arrayOf(key.publicKey))
+ store(out?.outputStream(), password)
+ }
+ }
+
+ private fun createKey(): KeySet {
+ var serialNumber: BigInteger
+
+ do serialNumber = SecureRandom().nextInt().toBigInteger()
+ while (serialNumber < BigInteger.ZERO)
+
+ val x500Name = X500Name("CN=Vendetta Manager")
+ val pair = KeyPairGenerator.getInstance("RSA").run {
+ initialize(2048)
+ generateKeyPair()
+ }
+ val builder = X509v3CertificateBuilder(
+ /* issuer = */ x500Name,
+ /* serial = */
+ serialNumber,
+ /* notBefore = */
+ Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L),
+ /* notAfter = */
+ Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L),
+ /* dateLocale = */
+ Locale.ENGLISH,
+ /* subject = */
+ x500Name,
+ /* publicKeyInfo = */
+ SubjectPublicKeyInfo.getInstance(pair.public.encoded)
+ )
+ val signer = JcaContentSignerBuilder("SHA1withRSA").build(pair.private)
+
+ return KeySet(
+ JcaX509CertificateConverter().getCertificate(builder.build(signer)),
+ pair.private
+ )
+ }
+
+ fun signApk(apkFile: File, output: File) {
+ val outputApk = cacheDir.resolve(apkFile.name)
+
+ ApkSigner.Builder(listOf(signerConfig))
+ .setV1SigningEnabled(false) // TODO: enable so api <24 devices can work, however zip-alignment breaks
+ .setV2SigningEnabled(true)
+ .setV3SigningEnabled(true)
+ .setInputApk(apkFile)
+ .setOutputApk(output)
+ .build()
+ .sign()
+
+ outputApk.renameTo(apkFile)
+ }
+
+ private class KeySet(val publicKey: X509Certificate, val privateKey: PrivateKey)
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/activity/MainActivity.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/activity/MainActivity.kt
new file mode 100644
index 0000000..9f61ffb
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/activity/MainActivity.kt
@@ -0,0 +1,24 @@
+package dev.beefers.vendetta.manager.ui.activity
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.transitions.SlideTransition
+import dev.beefers.vendetta.manager.ui.screen.main.MainScreen
+import dev.beefers.vendetta.manager.ui.theme.VendettaManagerTheme
+
+class MainActivity : ComponentActivity() {
+ @OptIn(ExperimentalAnimationApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ VendettaManagerTheme {
+ Navigator(MainScreen()) {
+ SlideTransition(it)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/RadioController.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/RadioController.kt
new file mode 100644
index 0000000..0b2e728
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/RadioController.kt
@@ -0,0 +1,51 @@
+package dev.beefers.vendetta.manager.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+inline fun > EnumRadioController(
+ default: E,
+ labelFactory: (E) -> String = { it.toString() },
+ crossinline onChoiceSelected: (E) -> Unit
+) {
+ var choice by remember { mutableStateOf(default) }
+ val ctx = LocalContext.current
+
+ Column {
+ enumValues().forEach {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ choice = it
+ onChoiceSelected(it)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(labelFactory(it))
+ Spacer(Modifier.weight(1f))
+ RadioButton(
+ selected = it == choice,
+ onClick = {
+ choice = it
+ onChoiceSelected(it)
+ })
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsButton.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsButton.kt
new file mode 100644
index 0000000..8307a69
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsButton.kt
@@ -0,0 +1,28 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SettingsButton(
+ label: String,
+ onClick: () -> Unit = {}
+) {
+ Box(
+ modifier = Modifier
+ .heightIn(min = 64.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 18.dp, vertical = 14.dp)
+ ) {
+ Button(onClick, modifier = Modifier.fillMaxWidth()) {
+ Text(text = label)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsChoiceDialog.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsChoiceDialog.kt
new file mode 100644
index 0000000..deac4e7
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsChoiceDialog.kt
@@ -0,0 +1,54 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.ui.components.EnumRadioController
+
+@Composable
+inline fun > SettingsChoiceDialog(
+ visible: Boolean = false,
+ default: E,
+ noinline title: @Composable () -> Unit,
+ crossinline labelFactory: (E) -> String = { it.toString() },
+ noinline onRequestClose: () -> Unit = {},
+ crossinline description: @Composable () -> Unit = {},
+ noinline onChoice: (E) -> Unit = {},
+) {
+
+ var choice by remember { mutableStateOf(default) }
+
+ AnimatedVisibility(
+ visible = visible,
+ enter = slideInVertically(),
+ exit = slideOutVertically()
+ ) {
+ AlertDialog(
+ onDismissRequest = { onRequestClose() },
+ title = title,
+ text = {
+ description()
+ EnumRadioController(
+ default,
+ labelFactory
+ ) { choice = it }
+ },
+ confirmButton = {
+ FilledTonalButton(onClick = { onChoice(choice) }) {
+ Text(text = stringResource(id = R.string.action_confirm))
+ }
+ }
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsHeader.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsHeader.kt
new file mode 100644
index 0000000..97af58b
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsHeader.kt
@@ -0,0 +1,19 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SettingsHeader(
+ text: String
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(18.dp, 24.dp, 18.dp, 10.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItem.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItem.kt
new file mode 100644
index 0000000..a94d499
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItem.kt
@@ -0,0 +1,64 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun SettingsItem(
+ modifier: Modifier = Modifier,
+ icon: @Composable (() -> Unit)? = null,
+ text: @Composable () -> Unit,
+ secondaryText: @Composable (() -> Unit) = { },
+ trailing: @Composable (() -> Unit) = { },
+) {
+ Row(
+ modifier = modifier
+ .heightIn(min = 64.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 18.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (icon != null) Box(modifier = Modifier.padding(8.dp)) {
+ icon()
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(5.dp)
+ ) {
+ ProvideTextStyle(
+ MaterialTheme.typography.titleLarge.copy(
+ fontWeight = FontWeight.Normal,
+ fontSize = 19.sp
+ )
+ ) {
+ text()
+ }
+ ProvideTextStyle(
+ MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.onSurface.copy(0.6f)
+ )
+ ) {
+ secondaryText()
+ }
+ }
+
+ Spacer(Modifier.weight(1f, true))
+
+ trailing()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItemChoice.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItemChoice.kt
new file mode 100644
index 0000000..baccee7
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsItemChoice.kt
@@ -0,0 +1,48 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+inline fun > SettingsItemChoice(
+ label: String,
+ title: String = label,
+ disabled: Boolean = false,
+ pref: E,
+ crossinline labelFactory: (E) -> String = { it.toString() },
+ crossinline onPrefChange: (E) -> Unit,
+) {
+ val ctx = LocalContext.current
+ val choiceLabel = labelFactory(pref)
+ var opened = remember {
+ mutableStateOf(false)
+ }
+
+ SettingsItem(
+ modifier = Modifier.clickable { opened.value = true },
+ text = { Text(text = label) },
+ ) {
+ SettingsChoiceDialog(
+ visible = opened.value,
+ title = { Text(title) },
+ default = pref,
+ labelFactory = labelFactory,
+ onRequestClose = {
+ opened.value = false
+ },
+ onChoice = {
+ opened.value = false
+ onPrefChange(it)
+ }
+ )
+ FilledTonalButton(onClick = { opened.value = true }, enabled = !disabled) {
+ Text(choiceLabel)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsSwitch.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsSwitch.kt
new file mode 100644
index 0000000..835bfec
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsSwitch.kt
@@ -0,0 +1,32 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun SettingsSwitch(
+ label: String,
+ secondaryLabel: String? = null,
+ disabled: Boolean = false,
+ pref: Boolean,
+ onPrefChange: (Boolean) -> Unit,
+) {
+ SettingsItem(
+ modifier = Modifier.clickable(enabled = !disabled) { onPrefChange(!pref) },
+ text = { Text(text = label) },
+ secondaryText = {
+ secondaryLabel?.let {
+ Text(text = it)
+ }
+ }
+ ) {
+ Switch(
+ checked = pref,
+ enabled = !disabled,
+ onCheckedChange = { onPrefChange(!pref) }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsTextField.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsTextField.kt
new file mode 100644
index 0000000..7beeefb
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/components/settings/SettingsTextField.kt
@@ -0,0 +1,29 @@
+package dev.beefers.vendetta.manager.ui.components.settings
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsTextField(
+ label: String,
+ disabled: Boolean = false,
+ pref: String,
+ error: Boolean = false,
+ onPrefChange: (String) -> Unit,
+) {
+ Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) {
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = pref,
+ onValueChange = onPrefChange,
+ enabled = !disabled,
+ label = { Text(label) },
+ isError = error,
+ singleLine = true
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/home/HomeScreen.kt
new file mode 100644
index 0000000..ead2fc3
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/home/HomeScreen.kt
@@ -0,0 +1,64 @@
+package dev.beefers.vendetta.manager.ui.screen.home
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.outlined.Home
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.ui.screen.installer.InstallerScreen
+import dev.beefers.vendetta.manager.utils.ManagerTab
+import dev.beefers.vendetta.manager.utils.TabOptions
+import dev.beefers.vendetta.manager.utils.navigate
+
+class HomeScreen : ManagerTab {
+ override val options: TabOptions
+ @Composable get() = TabOptions(
+ title = R.string.title_home,
+ selectedIcon = Icons.Filled.Home,
+ unselectedIcon = Icons.Outlined.Home
+ )
+
+ @Composable
+ override fun Content() {
+ val nav = LocalNavigator.currentOrThrow
+
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxSize()
+ ) {
+ Button(
+ onClick = { nav.navigate(InstallerScreen()) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.action_install))
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "This UI is temporary, check back later for something prettier",
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ @Composable
+ override fun Actions() {
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt
new file mode 100644
index 0000000..f50b867
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt
@@ -0,0 +1,110 @@
+package dev.beefers.vendetta.manager.ui.screen.installer
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+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.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.koin.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel
+import dev.beefers.vendetta.manager.ui.widgets.installer.StepGroupCard
+
+class InstallerScreen : Screen {
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ override fun Content() {
+ val viewModel: InstallerViewModel = getScreenModel()
+
+ var expandedGroup by remember {
+ mutableStateOf(null)
+ }
+
+ LaunchedEffect(viewModel.currentStep) {
+ expandedGroup = viewModel.currentStep?.group
+ }
+
+ Scaffold(
+ topBar = { TitleBar() }
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(it)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ for (group in InstallerViewModel.InstallStepGroup.values()) {
+ StepGroupCard(
+ name = stringResource(group.nameRes),
+ isCurrent = expandedGroup == group,
+ onClick = { expandedGroup = group },
+ steps = viewModel.getSteps(group),
+ )
+ }
+ if (viewModel.isFinished) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Button(
+ onClick = { viewModel.copyDebugInfo() },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringResource(R.string.action_copy_logs))
+ }
+ Button(
+ onClick = { viewModel.clearCache() },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringResource(R.string.action_clear_cache))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ @OptIn(ExperimentalMaterial3Api::class)
+ private fun TitleBar() {
+ val nav = LocalNavigator.currentOrThrow
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_installer)) },
+ navigationIcon = {
+ IconButton(onClick = { nav.pop() }) {
+ Icon(
+ imageVector = Icons.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.action_back)
+ )
+ }
+ }
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/main/MainScreen.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/main/MainScreen.kt
new file mode 100644
index 0000000..81aa667
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/main/MainScreen.kt
@@ -0,0 +1,115 @@
+package dev.beefers.vendetta.manager.ui.screen.main
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.koin.getScreenModel
+import com.google.accompanist.pager.ExperimentalPagerApi
+import com.google.accompanist.pager.HorizontalPager
+import com.google.accompanist.pager.PagerState
+import com.google.accompanist.pager.rememberPagerState
+import dev.beefers.vendetta.manager.BuildConfig
+import dev.beefers.vendetta.manager.ui.viewmodel.main.MainViewModel
+import dev.beefers.vendetta.manager.ui.widgets.updater.UpdateDialog
+import dev.beefers.vendetta.manager.utils.MainTab
+import kotlinx.coroutines.launch
+
+class MainScreen : Screen {
+
+ @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
+ @Composable
+ override fun Content() {
+ val viewModel: MainViewModel = getScreenModel()
+ val pagerState = rememberPagerState()
+
+ CompositionLocalProvider(
+ LocalPagerState provides pagerState
+ ) {
+ Scaffold(
+ bottomBar = { NavBar() },
+ topBar = { TitleBar() },
+ modifier = Modifier.fillMaxSize()
+ ) { pv ->
+ viewModel.release?.let {
+ if (it.versionCode > BuildConfig.VERSION_CODE) {
+ UpdateDialog(release = it) {
+ viewModel.downloadAndInstallUpdate()
+ }
+ }
+ }
+
+ HorizontalPager(
+ count = MainTab.values().size,
+ state = pagerState,
+ contentPadding = pv
+ ) { page ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ MainTab.values()[page].tab.Content()
+ }
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalPagerApi::class, ExperimentalMaterial3Api::class)
+ @Composable
+ private fun TitleBar() {
+ val pagerState = LocalPagerState.current
+ val tab = MainTab.values()[pagerState.currentPage].tab
+
+ TopAppBar(
+ title = { Text(tab.options.title) },
+ actions = { tab.Actions() }
+ )
+ }
+
+ @Composable
+ @OptIn(ExperimentalPagerApi::class)
+ private fun NavBar() {
+ val pagerState = LocalPagerState.current
+ val scope = rememberCoroutineScope()
+ val tab = MainTab.values()[pagerState.currentPage].tab
+
+ NavigationBar {
+ MainTab.values().forEach { mainTab ->
+ NavigationBarItem(
+ selected = mainTab.tab === tab,
+ onClick = {
+ val page = MainTab.values().indexOf(mainTab)
+ scope.launch {
+ pagerState.animateScrollToPage(page)
+ }
+ },
+ label = { Text(mainTab.tab.options.title) },
+ alwaysShowLabel = true,
+ icon = {
+ Icon(
+ painter = mainTab.tab.options.icon!!,
+ contentDescription = mainTab.tab.options.title
+ )
+ }
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalPagerApi::class)
+val LocalPagerState = compositionLocalOf {
+ error("Pager not initialized")
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/SettingsScreen.kt
new file mode 100644
index 0000000..74b138b
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/settings/SettingsScreen.kt
@@ -0,0 +1,95 @@
+package dev.beefers.vendetta.manager.ui.screen.settings
+
+import android.os.Build
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.koin.getScreenModel
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
+import dev.beefers.vendetta.manager.ui.components.settings.SettingsButton
+import dev.beefers.vendetta.manager.ui.components.settings.SettingsHeader
+import dev.beefers.vendetta.manager.ui.components.settings.SettingsItemChoice
+import dev.beefers.vendetta.manager.ui.components.settings.SettingsSwitch
+import dev.beefers.vendetta.manager.ui.components.settings.SettingsTextField
+import dev.beefers.vendetta.manager.ui.viewmodel.settings.SettingsViewModel
+import dev.beefers.vendetta.manager.utils.ManagerTab
+import dev.beefers.vendetta.manager.utils.TabOptions
+import org.koin.androidx.compose.get
+
+class SettingsScreen : ManagerTab {
+ override val options: TabOptions
+ @Composable get() = TabOptions(
+ title = R.string.title_settings,
+ selectedIcon = Icons.Filled.Settings,
+ unselectedIcon = Icons.Outlined.Settings
+ )
+
+ @Composable
+ override fun Content() {
+ val viewModel: SettingsViewModel = getScreenModel()
+ val prefs: PreferenceManager = get()
+ val ctx = LocalContext.current
+
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState())
+ ) {
+ SettingsHeader(stringResource(R.string.settings_appearance))
+ SettingsSwitch(
+ label = stringResource(R.string.settings_dynamic_color),
+ secondaryLabel = stringResource(R.string.settings_dynamic_color_description),
+ pref = prefs.monet,
+ onPrefChange = {
+ prefs.monet = it
+ },
+ disabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.S
+ )
+ SettingsItemChoice(
+ label = stringResource(R.string.settings_theme),
+ pref = prefs.theme,
+ labelFactory = {
+ ctx.getString(it.labelRes)
+ },
+ onPrefChange = {
+ prefs.theme = it
+ }
+ )
+
+ SettingsHeader(stringResource(R.string.settings_advanced))
+ SettingsTextField(
+ label = stringResource(R.string.settings_app_name),
+ pref = prefs.appName,
+ onPrefChange = {
+ prefs.appName = it
+ }
+ )
+ SettingsSwitch(
+ label = stringResource(R.string.settings_app_icon),
+ secondaryLabel = stringResource(R.string.settings_app_icon_description),
+ pref = prefs.patchIcon,
+ onPrefChange = {
+ prefs.patchIcon = it
+ }
+ )
+
+ SettingsButton(
+ label = stringResource(R.string.action_clear_cache),
+ onClick = {
+ viewModel.clearCache()
+ }
+ )
+ }
+ }
+
+ @Composable
+ override fun Actions() {
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Theme.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Theme.kt
new file mode 100644
index 0000000..f7ec444
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Theme.kt
@@ -0,0 +1,57 @@
+package dev.beefers.vendetta.manager.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
+import dev.beefers.vendetta.manager.domain.manager.Theme
+import org.koin.androidx.compose.get
+
+@Composable
+fun VendettaManagerTheme(
+ content: @Composable () -> Unit
+) {
+ val prefs = get()
+ val dynamicColor = prefs.monet
+ val darkTheme = when (prefs.theme) {
+ Theme.SYSTEM -> isSystemInDarkTheme()
+ Theme.DARK -> true
+ Theme.LIGHT -> false
+ }
+ val systemUiController = rememberSystemUiController()
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = !darkTheme)
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Type.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Type.kt
new file mode 100644
index 0000000..e457eb9
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/theme/Type.kt
@@ -0,0 +1,6 @@
+package dev.beefers.vendetta.manager.ui.theme
+
+import androidx.compose.material3.Typography
+
+// Set of Material typography styles to start with
+val Typography = Typography()
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt
new file mode 100644
index 0000000..4843aad
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt
@@ -0,0 +1,450 @@
+package dev.beefers.vendetta.manager.ui.viewmodel.installer
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import com.github.diamondminer88.zip.ZipCompression
+import com.github.diamondminer88.zip.ZipReader
+import com.github.diamondminer88.zip.ZipWriter
+import dev.beefers.vendetta.manager.BuildConfig
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.domain.manager.DownloadManager
+import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
+import dev.beefers.vendetta.manager.installer.util.ManifestPatcher
+import dev.beefers.vendetta.manager.installer.util.Patcher
+import dev.beefers.vendetta.manager.installer.util.installApks
+import dev.beefers.vendetta.manager.network.utils.Signer
+import dev.beefers.vendetta.manager.utils.copyText
+import dev.beefers.vendetta.manager.utils.showToast
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.lsposed.patch.util.Logger
+import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.ExperimentalTime
+import kotlin.time.measureTimedValue
+
+class InstallerViewModel(
+ private val context: Context,
+ private val downloadManager: DownloadManager,
+ private val preferences: PreferenceManager
+) : ScreenModel {
+ private val installationRunning = AtomicBoolean(false)
+ private val cacheDir = context.externalCacheDir!!
+ private var debugInfo = """
+ Vendetta Manager ${BuildConfig.VERSION_NAME}
+ Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} ${if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes Present)" else ""}
+
+ Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}
+ Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
+
+
+ """.trimIndent()
+
+ private val logger = object : Logger() {
+ override fun d(msg: String?) {
+ if (msg != null) {
+ Log.d("Installer", msg)
+ debugInfo += "$msg\n"
+ }
+ }
+
+ override fun i(msg: String?) {
+ if (msg != null) {
+ Log.i("Installer", msg)
+ debugInfo += "$msg\n"
+ }
+ }
+
+ override fun e(msg: String?) {
+ if (msg != null) {
+ Log.e("Installer", msg)
+ debugInfo += "$msg\n"
+ }
+ }
+ }
+
+ fun copyDebugInfo() {
+ context.copyText(debugInfo)
+ }
+
+ fun clearCache() {
+ cacheDir.deleteRecursively()
+ context.showToast(R.string.msg_cleared_cache)
+ }
+
+ private val job = coroutineScope.launch(Dispatchers.Main) {
+ if (installationRunning.getAndSet(true)) {
+ return@launch
+ }
+
+ withContext(Dispatchers.IO) {
+ install()
+ }
+ }
+
+ private suspend fun install() {
+ steps += listOf(
+ InstallStep.DL_BASE_APK to InstallStepData(
+ InstallStep.DL_BASE_APK.nameRes,
+ InstallStatus.QUEUED
+ ),
+ InstallStep.DL_LIBS_APK to InstallStepData(
+ InstallStep.DL_LIBS_APK.nameRes,
+ InstallStatus.QUEUED
+ ),
+ InstallStep.DL_LANG_APK to InstallStepData(
+ InstallStep.DL_LANG_APK.nameRes,
+ InstallStatus.QUEUED
+ ),
+ InstallStep.DL_RESC_APK to InstallStepData(
+ InstallStep.DL_RESC_APK.nameRes,
+ InstallStatus.QUEUED
+ ),
+ InstallStep.DL_VD to InstallStepData(InstallStep.DL_VD.nameRes, InstallStatus.QUEUED),
+ InstallStep.ADD_VD to InstallStepData(InstallStep.ADD_VD.nameRes, InstallStatus.QUEUED),
+ InstallStep.PATCH_MANIFESTS to InstallStepData(
+ InstallStep.PATCH_MANIFESTS.nameRes,
+ InstallStatus.QUEUED
+ ),
+ InstallStep.SIGN_APK to InstallStepData(
+ InstallStep.SIGN_APK.nameRes,
+ InstallStatus.QUEUED
+ ),
+ InstallStep.INSTALL_APK to InstallStepData(
+ InstallStep.INSTALL_APK.nameRes,
+ InstallStatus.QUEUED
+ ),
+ )
+
+ if (preferences.patchIcon) steps += InstallStep.CHANGE_ICON to InstallStepData(
+ InstallStep.CHANGE_ICON.nameRes,
+ InstallStatus.QUEUED
+ )
+
+ val version = preferences.discordVersion.ifBlank { "168018" }
+ val arch = Build.SUPPORTED_ABIS.first()
+ val discordCacheDir = cacheDir.resolve(version)
+ val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() }
+ val signedDir = discordCacheDir.resolve("signed").also { it.deleteRecursively() }
+ val lspatchedDir = patchedDir.resolve("lspatched").also { it.deleteRecursively() }
+
+ // Download base.apk
+ val baseApk = step(InstallStep.DL_BASE_APK) {
+ discordCacheDir.resolve("base-$version.apk").let { file ->
+ logger.i("Checking if base-$version.apk is cached")
+ if (file.exists()) {
+ cached = true
+ logger.i("base-$version.apk is cached")
+ } else {
+ logger.i("base-$version.apk is not cached, downloading now")
+ downloadManager.downloadDiscordApk(version, file)
+ }
+
+ logger.i("Move base-$version.apk to working directory")
+ file.copyTo(
+ patchedDir.resolve(file.name),
+ true
+ )
+ }
+ }
+
+ // Download libs apk
+ val libsApk = step(InstallStep.DL_LIBS_APK) {
+ val libArch = arch.replace("-v", "_v")
+ discordCacheDir.resolve("config.$libArch-$version.apk").let { file ->
+ logger.i("Checking if config.$libArch-$version.apk is cached")
+ if (file.exists()) {
+ logger.i("config.$libArch-$version.apk is cached")
+ cached = true
+ } else {
+ logger.i("config.$libArch-$version.apk is not cached, downloading now")
+ downloadManager.downloadSplit(
+ version = version,
+ split = "config.$libArch",
+ out = file
+ )
+ }
+
+ logger.i("Move config.$libArch-$version.apk to working directory")
+ file.copyTo(
+ patchedDir.resolve(file.name),
+ true
+ )
+ }
+ }
+
+ // Download locale apk
+ val langApk = step(InstallStep.DL_LANG_APK) {
+ discordCacheDir.resolve("config.en-$version.apk").let { file ->
+ logger.i("Checking if config.en-$version.apk is cached")
+ if (file.exists()) {
+ logger.i("config.en-$version.apk is cached")
+ cached = true
+ } else {
+ logger.i("config.en-$version.apk is not cached, downloading now")
+ downloadManager.downloadSplit(
+ version = version,
+ split = "config.en",
+ out = file
+ )
+ }
+
+ logger.i("Move config.en-$version.apk to working directory")
+ file.copyTo(
+ patchedDir.resolve(file.name),
+ true
+ )
+ }
+ }
+
+ // Download resources apk
+ val resApk = step(InstallStep.DL_RESC_APK) {
+ discordCacheDir.resolve("config.xxhdpi-$version.apk").let { file ->
+ logger.i("Checking if config.xxhdpi-$version.apk is cached")
+ if (file.exists()) {
+ logger.i("config.xxhdpi-$version.apk is cached")
+ cached = true
+ } else {
+ logger.i("config.xxhdpi-$version.apk is not cached, downloading now")
+ downloadManager.downloadSplit(
+ version = version,
+ split = "config.xxhdpi",
+ out = file
+ )
+ }
+
+ logger.i("Move config.xxhdpi-$version.apk to working directory")
+ file.copyTo(
+ patchedDir.resolve(file.name),
+ true
+ )
+ }
+ }
+
+ // Download vendetta apk
+ val vendetta = step(InstallStep.DL_VD) {
+ discordCacheDir.resolve("vendetta.apk").let { file ->
+ logger.i("Checking if vendetta.apk is cached")
+ if (file.exists()) {
+ logger.i("vendetta.apk is cached")
+ cached = true
+ } else {
+ logger.i("vendetta.apk is not cached, downloading now")
+ downloadManager.downloadVendetta(file)
+ }
+
+ logger.i("Move vendetta.apk to working directory")
+ file.copyTo(
+ patchedDir.resolve(file.name),
+ true
+ )
+ }
+ }
+
+ if (preferences.patchIcon) {
+ step(InstallStep.CHANGE_ICON) {
+ ZipWriter(baseApk, true).use { baseApk ->
+ val mipmaps =
+ arrayOf("mipmap-xhdpi-v4", "mipmap-xxhdpi-v4", "mipmap-xxxhdpi-v4")
+ val icons = arrayOf(
+ "ic_logo_foreground.png",
+ "ic_logo_square.png",
+ "ic_logo_foreground.png"
+ )
+
+ for (icon in icons) {
+ val newIcon = context.assets.open("icons/$icon")
+ .use { it.readBytes() }
+
+ for (mipmap in mipmaps) {
+ logger.i("Replacing $mipmap with $icon")
+ val path = "res/$mipmap/$icon"
+ baseApk.deleteEntry(path)
+ baseApk.writeEntry(path, newIcon)
+ }
+ }
+ }
+ }
+ }
+
+ // Patch manifests
+ step(InstallStep.PATCH_MANIFESTS) {
+ arrayOf(baseApk, libsApk, langApk, resApk).forEach { apk ->
+ logger.i("Reading AndroidManifest.xml from ${apk!!.name}")
+ val manifest = ZipReader(apk)
+ .use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
+ ?: throw IllegalStateException("No manifest in ${apk.name}")
+
+ ZipWriter(apk, true).use { zip ->
+ logger.i("Changing package and app name in ${apk.name}")
+ val patchedManifestBytes = if (apk == baseApk) {
+ ManifestPatcher.patchManifest(
+ manifestBytes = manifest,
+ packageName = preferences.packageName,
+ appName = preferences.appName,
+ debuggable = preferences.debuggable,
+ )
+ } else {
+ logger.i("Changing package name in ${apk.name}")
+ ManifestPatcher.renamePackage(manifest, preferences.packageName)
+ }
+
+ logger.i("Deleting old AndroidManifest.xml in ${apk.name}")
+ zip.deleteEntry(
+ "AndroidManifest.xml",
+ apk == libsApk
+ ) // Preserve alignment in libs apk
+ logger.i("Adding patched AndroidManifest.xml in ${apk.name}")
+ zip.writeEntry("AndroidManifest.xml", patchedManifestBytes)
+ }
+ }
+ }
+
+ step(InstallStep.SIGN_APK) {
+ // Align resources.arsc due to targeting api 30 for silent install
+ logger.i("Creating dir for signed apks")
+ signedDir.mkdir()
+ val apks = arrayOf(baseApk, libsApk, langApk, resApk)
+ if (Build.VERSION.SDK_INT >= 31) {
+ for (file in apks) {
+ logger.i("Byte aligning ${file!!.name}")
+ val bytes = ZipReader(file).use {
+ if (it.entryNames.contains("resources.arsc")) {
+ it.openEntry("resources.arsc")?.read()
+ } else {
+ null
+ }
+ } ?: continue
+
+ ZipWriter(file, true).use {
+ logger.i("Removing old resources.arsc")
+ it.deleteEntry("resources.arsc", true)
+ logger.i("Adding aligned resources.arsc")
+ it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
+ }
+ }
+ }
+
+ apks.forEach {
+ logger.i("Signing ${it!!.name}")
+ Signer.signApk(it, File(signedDir, it.name))
+ }
+ }
+
+ step(InstallStep.ADD_VD) {
+ val files = mutableListOf()
+ signedDir.list { _, name ->
+ files.add(signedDir.resolve(name))
+ }
+ Patcher.patch(
+ logger,
+ outputDir = lspatchedDir,
+ apkPaths = files.map { it.absolutePath },
+ embeddedModules = listOf(vendetta!!.absolutePath)
+ )
+ }
+
+ step(InstallStep.INSTALL_APK) {
+ logger.i("Gathering final apks")
+ val files = mutableListOf()
+ lspatchedDir.list { _, name ->
+ files.add(lspatchedDir.resolve(name))
+ }
+ logger.i("Installing apks")
+ context.installApks(true, *files.toTypedArray())
+ isFinished = true
+ }
+ }
+
+ @OptIn(ExperimentalTime::class)
+ private inline fun step(step: InstallStep, block: InstallStepData.() -> T): T? {
+ if (isFinished) return null
+ steps[step]!!.status = InstallStatus.ONGOING
+ currentStep = step
+
+ try {
+ val value = measureTimedValue { block.invoke(steps[step]!!) }
+ val millis = value.duration.inWholeMilliseconds
+
+ steps[step]!!.apply {
+ duration = millis.div(1000f)
+ status = InstallStatus.SUCCESSFUL
+ }
+ return value.value
+ } catch (e: Throwable) {
+ steps[step]!!.status = InstallStatus.UNSUCCESSFUL
+
+ logger.e("\nFailed on step ${step.name}\n")
+ logger.e(e.stackTraceToString())
+
+ currentStep = step
+ isFinished = true
+ return null
+ }
+ }
+
+ enum class InstallStepGroup(@StringRes val nameRes: Int) {
+ DL(R.string.group_download),
+ PATCHING(R.string.group_patch),
+ INSTALLING(R.string.group_installing)
+ }
+
+ enum class InstallStep(
+ val group: InstallStepGroup,
+ @StringRes val nameRes: Int
+ ) {
+ DL_BASE_APK(InstallStepGroup.DL, R.string.step_dl_base),
+ DL_LIBS_APK(InstallStepGroup.DL, R.string.step_dl_lib),
+ DL_LANG_APK(InstallStepGroup.DL, R.string.step_dl_lang),
+ DL_RESC_APK(InstallStepGroup.DL, R.string.step_dl_res),
+ DL_VD(InstallStepGroup.DL, R.string.step_dl_vd),
+
+ CHANGE_ICON(InstallStepGroup.PATCHING, R.string.step_change_icon),
+ PATCH_MANIFESTS(InstallStepGroup.PATCHING, R.string.step_patch_manifests),
+ SIGN_APK(InstallStepGroup.PATCHING, R.string.step_signing),
+ ADD_VD(InstallStepGroup.PATCHING, R.string.step_add_vd),
+
+ INSTALL_APK(InstallStepGroup.INSTALLING, R.string.step_installing)
+ }
+
+ enum class InstallStatus {
+ ONGOING,
+ SUCCESSFUL,
+ UNSUCCESSFUL,
+ QUEUED
+ }
+
+ @Stable
+ class InstallStepData(
+ @StringRes val nameRes: Int,
+ status: InstallStatus,
+ duration: Float = 0f,
+ cached: Boolean = false
+ ) {
+ var status by mutableStateOf(status)
+ var duration by mutableStateOf(duration)
+ var cached by mutableStateOf(cached)
+ }
+
+ var currentStep by mutableStateOf(null)
+ val steps = mutableStateMapOf()
+ var isFinished by mutableStateOf(false)
+
+ fun getSteps(group: InstallStepGroup): List {
+ return steps
+ .filterKeys { it.group === group }.entries
+ .sortedBy { it.key.ordinal }
+ .map { it.value }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/main/MainViewModel.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/main/MainViewModel.kt
new file mode 100644
index 0000000..c33546f
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/main/MainViewModel.kt
@@ -0,0 +1,44 @@
+package dev.beefers.vendetta.manager.ui.viewmodel.main
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import dev.beefers.vendetta.manager.domain.manager.DownloadManager
+import dev.beefers.vendetta.manager.domain.repository.GithubRepository
+import dev.beefers.vendetta.manager.installer.util.installApks
+import dev.beefers.vendetta.manager.network.dto.Release
+import dev.beefers.vendetta.manager.network.utils.dataOrNull
+import kotlinx.coroutines.launch
+import java.io.File
+
+class MainViewModel(
+ private val githubRepo: GithubRepository,
+ private val downloadManager: DownloadManager,
+ private val context: Context
+) : ScreenModel {
+ private val downloadDir = context.cacheDir
+ var release by mutableStateOf(null)
+ private set
+
+ init {
+ checkForUpdate()
+ }
+
+ private fun checkForUpdate() {
+ coroutineScope.launch {
+ release = githubRepo.getLatestRelease().dataOrNull
+ }
+ }
+
+ fun downloadAndInstallUpdate() {
+ coroutineScope.launch {
+ val update = File(downloadDir, "update.apk")
+ downloadManager.downloadUpdate(update)
+ context.installApks(false, update)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/settings/SettingsViewModel.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..60b91c3
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/settings/SettingsViewModel.kt
@@ -0,0 +1,18 @@
+package dev.beefers.vendetta.manager.ui.viewmodel.settings
+
+import android.content.Context
+import cafe.adriel.voyager.core.model.ScreenModel
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.utils.showToast
+
+class SettingsViewModel(
+ private val context: Context
+) : ScreenModel {
+ private val cacheDir = context.externalCacheDir!!
+
+ fun clearCache() {
+ cacheDir.deleteRecursively()
+ context.showToast(R.string.msg_cleared_cache)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt
new file mode 100644
index 0000000..444088d
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt
@@ -0,0 +1,137 @@
+package dev.beefers.vendetta.manager.ui.widgets.installer
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel
+
+@Composable
+fun StepGroupCard(
+ name: String,
+ isCurrent: Boolean,
+ steps: List,
+ onClick: () -> Unit
+) {
+ val status = when {
+ steps.all { it.status == InstallerViewModel.InstallStatus.QUEUED } -> InstallerViewModel.InstallStatus.QUEUED
+ steps.all { it.status == InstallerViewModel.InstallStatus.SUCCESSFUL } -> InstallerViewModel.InstallStatus.SUCCESSFUL
+ steps.any { it.status == InstallerViewModel.InstallStatus.ONGOING } -> InstallerViewModel.InstallStatus.ONGOING
+ else -> InstallerViewModel.InstallStatus.UNSUCCESSFUL
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .run {
+ if (isCurrent) {
+ background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
+ } else this
+ }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ StepIcon(status, 24.dp)
+
+ Text(text = name)
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ if (status != InstallerViewModel.InstallStatus.ONGOING && status != InstallerViewModel.InstallStatus.QUEUED) {
+ Text(
+ text = "%.2fs".format(steps.map { it.duration }.sum()),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ val (arrow, cd) = when {
+ isCurrent -> Icons.Filled.KeyboardArrowUp to R.string.action_collapse
+ else -> Icons.Filled.KeyboardArrowDown to R.string.action_expand
+ }
+
+ Icon(
+ imageVector = arrow,
+ contentDescription = stringResource(cd)
+ )
+ }
+
+ AnimatedVisibility(visible = isCurrent) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.background.copy(0.6f))
+ .fillMaxWidth()
+ .padding(16.dp)
+ .padding(start = 4.dp)
+ ) {
+ steps.forEach {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ StepIcon(it.status, size = 18.dp)
+
+ Text(
+ text = stringResource(it.nameRes),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, true),
+ )
+
+ if (it.status != InstallerViewModel.InstallStatus.ONGOING && it.status != InstallerViewModel.InstallStatus.QUEUED) {
+ if (it.cached) {
+ val style = MaterialTheme.typography.labelSmall.copy(
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ fontStyle = FontStyle.Italic,
+ fontSize = 11.sp
+ )
+ Text(
+ text = stringResource(R.string.installer_cached),
+ style = style,
+ maxLines = 1,
+ )
+ }
+
+ Text(
+ text = "%.2fs".format(it.duration),
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt
new file mode 100644
index 0000000..722d152
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt
@@ -0,0 +1,71 @@
+package dev.beefers.vendetta.manager.ui.widgets.installer
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel
+import kotlin.math.floor
+
+@Composable
+fun StepIcon(
+ status: InstallerViewModel.InstallStatus,
+ size: Dp
+) {
+ val strokeWidth = Dp(floor(size.value / 10) + 1)
+ val context = LocalContext.current
+
+ when (status) {
+ InstallerViewModel.InstallStatus.ONGOING -> {
+ CircularProgressIndicator(
+ strokeWidth = strokeWidth,
+ modifier = Modifier
+ .size(size)
+ .semantics {
+ contentDescription = context.getString(R.string.status_ongoing)
+ }
+ )
+ }
+
+ InstallerViewModel.InstallStatus.SUCCESSFUL -> {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = stringResource(R.string.status_successful),
+ tint = Color(0xFF59B463),
+ modifier = Modifier.size(size)
+ )
+ }
+
+ InstallerViewModel.InstallStatus.UNSUCCESSFUL -> {
+ Icon(
+ imageVector = Icons.Filled.Cancel,
+ contentDescription = stringResource(R.string.status_fail),
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(size)
+ )
+ }
+
+ InstallerViewModel.InstallStatus.QUEUED -> {
+ Icon(
+ imageVector = Icons.Outlined.Circle,
+ contentDescription = stringResource(R.string.status_queued),
+ tint = LocalContentColor.current.copy(alpha = 0.4f),
+ modifier = Modifier.size(size)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/updater/UpdateDialog.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/updater/UpdateDialog.kt
new file mode 100644
index 0000000..b9b7974
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/updater/UpdateDialog.kt
@@ -0,0 +1,39 @@
+package dev.beefers.vendetta.manager.ui.widgets.updater
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.SystemUpdate
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import dev.beefers.vendetta.manager.R
+import dev.beefers.vendetta.manager.network.dto.Release
+
+@Composable
+fun UpdateDialog(
+ release: Release,
+ onConfirm: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ FilledTonalButton(onClick = onConfirm) {
+ Text(stringResource(R.string.action_update))
+ }
+ },
+ title = {
+ Text(stringResource(R.string.title_update))
+ },
+ text = {
+ Text(stringResource(R.string.update_description, release.versionName))
+ },
+ icon = {
+ Icon(
+ imageVector = Icons.Filled.SystemUpdate,
+ contentDescription = null
+ )
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/utils/NavUtils.kt b/app/src/main/java/dev/beefers/vendetta/manager/utils/NavUtils.kt
new file mode 100644
index 0000000..bdc80af
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/utils/NavUtils.kt
@@ -0,0 +1,57 @@
+package dev.beefers.vendetta.manager.utils
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.tab.Tab
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import com.google.accompanist.pager.ExperimentalPagerApi
+import dev.beefers.vendetta.manager.ui.screen.home.HomeScreen
+import dev.beefers.vendetta.manager.ui.screen.main.LocalPagerState
+import dev.beefers.vendetta.manager.ui.screen.settings.SettingsScreen
+
+enum class MainTab(val tab: ManagerTab) {
+ HOME(HomeScreen()),
+ SETTINGS(SettingsScreen())
+}
+
+@OptIn(ExperimentalPagerApi::class)
+@Composable
+fun Tab.TabOptions(
+ @StringRes title: Int,
+ selectedIcon: ImageVector,
+ unselectedIcon: ImageVector
+): TabOptions {
+ val pagerState = LocalPagerState.current
+ val selected = MainTab.values()[pagerState.currentPage].tab == this
+ val selectedIconPainter = rememberVectorPainter(
+ image = selectedIcon
+ )
+ val unelectedIconPainter = rememberVectorPainter(
+ image = unselectedIcon
+ )
+
+ return TabOptions(
+ 0u,
+ stringResource(title),
+ if (selected) selectedIconPainter else unelectedIconPainter
+ )
+}
+
+tailrec fun Navigator.navigate(screen: Screen) {
+ if (level == 0)
+ push(screen)
+ else
+ this.parent!!.navigate(screen)
+}
+
+interface ManagerTab : Tab {
+
+ @Composable
+ fun Actions()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/utils/Utils.kt b/app/src/main/java/dev/beefers/vendetta/manager/utils/Utils.kt
new file mode 100644
index 0000000..ccabc01
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/utils/Utils.kt
@@ -0,0 +1,22 @@
+package dev.beefers.vendetta.manager.utils
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.widget.Toast
+import androidx.annotation.StringRes
+import dev.beefers.vendetta.manager.BuildConfig
+
+fun Context.copyText(text: String) {
+ val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+
+ clipboardManager.setPrimaryClip(ClipData.newPlainText(BuildConfig.APPLICATION_ID, text))
+}
+
+fun Context.showToast(@StringRes res: Int, vararg params: Any, short: Boolean = true) {
+ Toast.makeText(
+ this,
+ getString(res, *params),
+ if (short) Toast.LENGTH_SHORT else Toast.LENGTH_LONG
+ ).show()
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/beefers/vendetta/manager/utils/VersionUtils.kt b/app/src/main/java/dev/beefers/vendetta/manager/utils/VersionUtils.kt
new file mode 100644
index 0000000..6869d51
--- /dev/null
+++ b/app/src/main/java/dev/beefers/vendetta/manager/utils/VersionUtils.kt
@@ -0,0 +1,32 @@
+package dev.beefers.vendetta.manager.utils
+
+data class DiscordVersion(
+ val major: Int,
+ val minor: Int,
+ val type: Type
+) {
+
+ enum class Type {
+ STABLE,
+ BETA,
+ ALPHA
+ }
+
+ companion object {
+
+ fun fromVersionCode(string: String): DiscordVersion? = with(string) {
+ if (length < 4) return@with null
+ if (toIntOrNull() == null) return@with null
+ val codeReversed = toCharArray().reversed().joinToString("")
+ val typeInt = codeReversed[2].toString().toInt()
+ val type = Type.values().getOrNull(typeInt) ?: return@with null
+ DiscordVersion(
+ codeReversed.slice(3..codeReversed.lastIndex).toInt(),
+ codeReversed.substring(0, 2).toInt(),
+ type
+ )
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable-v26/ic_launcher.xml b/app/src/main/res/drawable-v26/ic_launcher.xml
new file mode 100644
index 0000000..d76c7de
--- /dev/null
+++ b/app/src/main/res/drawable-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable-v26/vendetta_logo.xml b/app/src/main/res/drawable-v26/vendetta_logo.xml
new file mode 100644
index 0000000..e10adaf
--- /dev/null
+++ b/app/src/main/res/drawable-v26/vendetta_logo.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher.png b/app/src/main/res/drawable/ic_launcher.png
new file mode 100644
index 0000000..5848f4e
Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher.png differ
diff --git a/app/src/main/res/drawable/vendetta_logo.xml b/app/src/main/res/drawable/vendetta_logo.xml
new file mode 100644
index 0000000..e10adaf
--- /dev/null
+++ b/app/src/main/res/drawable/vendetta_logo.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..e19cd74
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,11 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+ #FF3AB8BA
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3e16e3d
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,63 @@
+
+ Vendetta Manager
+
+ Cache cleared successfully
+
+ Download APKs
+ Patching
+ Installing
+
+ Downloading base apk
+ Downloading libraries apk
+ Downloading language apk
+ Downloading resources apk
+ Downloading Vendetta module
+
+ Changing app icon
+ Patching app manifests
+ Injecting Vendetta
+
+ Signing APKs
+ Installing APKs
+
+ Installed successfully
+ Install canceled
+ Failed to install: %d
+
+ Ongoing
+ Successful
+ Failed
+ Queued
+
+ Collapse
+ Expand
+ Go back
+ Copy logs
+ Clear cache
+ Confirm
+ Start update
+ Install
+
+ Cached
+
+ Installer
+ Home
+ Settings
+ Update available!
+
+ System
+ Light
+ Dark
+
+ Appearance
+ Dynamic color
+ Only available on Android 12 and up
+ Theme
+
+ Advanced
+ App name
+ Replace app icon
+ Uses the Vendetta icon instead of Discord\'s
+
+ Vendetta Manager version %1$s is now available!
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..5fcbb32
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/dev/beefers/vendetta/manager/ExampleUnitTest.kt b/app/src/test/java/dev/beefers/vendetta/manager/ExampleUnitTest.kt
new file mode 100644
index 0000000..bfa4b29
--- /dev/null
+++ b/app/src/test/java/dev/beefers/vendetta/manager/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package dev.beefers.vendetta.manager
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..0efeeda
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,19 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id("com.android.application") version "8.1.0-alpha02" apply false
+ id("com.android.library") version "8.1.0-alpha02" apply false
+ kotlin("android") version "1.7.20" apply false
+}
+
+tasks.withType().all {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ maven("https://maven.aliucord.com/snapshots")
+ maven("https://jitpack.io")
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..3c5031e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ab375d9
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Mar 14 14:35:22 EDT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-rc-1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..6733591
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,10 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+rootProject.name = "Vendetta Manager"
+include(":app")