Initial ui and functionality

This commit is contained in:
wingio 2023-03-14 18:43:14 -04:00
parent 82ce3d2d48
commit 92a650b067
79 changed files with 3522 additions and 0 deletions

1
.idea/.name Normal file
View File

@ -0,0 +1 @@
Vendetta Manager

6
.idea/compiler.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

7
.idea/discord.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

35
.idea/jarRepositories.xml Normal file
View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://maven.aliucord.com/snapshots" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
</component>
</project>

6
.idea/kotlinc.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.20" />
</component>
</project>

5
.idea/misc.xml Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK" />
</project>

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

165
app/build.gradle.kts Normal file
View File

@ -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
}
}

BIN
app/libs/lspatch.jar Normal file

Binary file not shown.

43
app/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -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"
}

View File

@ -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)
}
}

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.REQUEST_DELETE_PACKAGES"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.DELETE_PACKAGES"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.VendettaManager"
android:name=".ManagerApplication"
tools:targetApi="31">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.VendettaManager">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".installer.service.InstallService" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
)
}
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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<DownloadManager>()!!
// 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/"
}
}

View File

@ -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)
}

View File

@ -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 <reified E : Enum<E>> getEnum(key: String, defaultValue: E) =
enumValueOf<E>(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 <reified E : Enum<E>> putEnum(key: String, value: E) =
putString(key, value.name)
protected class Preference<T>(
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 <reified E : Enum<E>> enumPreference(
key: String,
defaultValue: E
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getEnum,
setter = ::putEnum
)
}

View File

@ -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()
}

View File

@ -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>(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
}

View File

@ -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<String, Any>
) : 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
)
}
}
}

View File

@ -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()
}

View File

@ -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<String>,
private val embeddedModules: List<String>?
) {
fun toStringArray(): Array<String> {
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<String>,
embeddedModules: List<String>
) {
withContext(Dispatchers.IO) {
LSPatch(
logger,
*apkPaths.toTypedArray(),
"-o",
outputDir.absolutePath,
"-l",
"2",
"-v",
"-m",
*embeddedModules.toTypedArray(),
"-k",
Signer.keyStore.absolutePath,
"password",
"alias",
"password"
).doCommandLine()
}
}
}

View File

@ -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
)

View File

@ -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<Release> {
url("https://api.github.com/repos/vendetta-mod/VendettaManager/releases/latest")
}
}
}

View File

@ -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 <reified T> request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse<T> {
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<T>(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
}
}

View File

@ -0,0 +1,26 @@
package dev.beefers.vendetta.manager.network.utils
import io.ktor.http.HttpStatusCode
sealed interface ApiResponse<D> {
data class Success<D>(val data: D) : ApiResponse<D>
data class Error<D>(val error: ApiError) : ApiResponse<D>
data class Failure<D>(val error: ApiFailure) : ApiResponse<D>
}
class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
class ApiFailure(error: Throwable, body: String?) : Error(body, error)
val <D> ApiResponse<D>.dataOrNull
get() = if (this is ApiResponse.Success) data else null
val <D> ApiResponse<D>.dataOrThrow
get() = when (this) {
is ApiResponse.Success -> data
is ApiResponse.Error -> throw error
is ApiResponse.Failure -> throw error
}
inline fun <D> ApiResponse<D>.ifSuccessful(block: (D) -> Unit) {
if (this is ApiResponse.Success) block(data)
}

View File

@ -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<Context>().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<Certificate>(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)
}

View File

@ -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)
}
}
}
}
}

View File

@ -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 <reified E : Enum<E>> EnumRadioController(
default: E,
labelFactory: (E) -> String = { it.toString() },
crossinline onChoiceSelected: (E) -> Unit
) {
var choice by remember { mutableStateOf(default) }
val ctx = LocalContext.current
Column {
enumValues<E>().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)
})
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 <reified E : Enum<E>> 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))
}
}
)
}
}

View File

@ -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)
)
}

View File

@ -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()
}
}

View File

@ -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 <reified E : Enum<E>> 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)
}
}
}

View File

@ -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) }
)
}
}

View File

@ -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
)
}
}

View File

@ -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() {
}
}

View File

@ -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<InstallerViewModel.InstallStepGroup?>(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)
)
}
}
)
}
}

View File

@ -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<PagerState> {
error("Pager not initialized")
}

View File

@ -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() {
}
}

View File

@ -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<PreferenceManager>()
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
)
}

View File

@ -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()

View File

@ -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<File>()
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<File>()
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 <T> 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<InstallStep?>(null)
val steps = mutableStateMapOf<InstallStep, InstallStepData>()
var isFinished by mutableStateOf(false)
fun getSteps(group: InstallStepGroup): List<InstallStepData> {
return steps
.filterKeys { it.group === group }.entries
.sortedBy { it.key.ordinal }
.map { it.value }
}
}

View File

@ -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<Release?>(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)
}
}
}

View File

@ -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)
}
}

View File

@ -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<InstallerViewModel.InstallStepData>,
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,
)
}
}
}
}
}
}
}

View File

@ -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)
)
}
}
}

View File

@ -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
)
}
)
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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
)
}
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/vendetta_brand" />
<foreground android:drawable="@drawable/vendetta_logo" />
<monochrome android:drawable="@drawable/vendetta_logo" />
</adaptive-icon>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1024dp"
android:height="1024dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#3AB8BA"
android:fillAlpha="0"/>
<path
android:pathData="M520.1,592.7C520,592.7 519.9,592.7 519.8,592.7L519.8,592.7L519.8,592.7L519.8,420.2C519.9,420.2 520,420.2 520.1,420.2L520.1,365.1C520.1,350.3 531.9,338.3 546.5,338.3L585.3,338.3C585.7,339.3 586.7,340.1 587.9,340.1L618.9,340.1L618.9,340.1L618.9,340.1C620.1,340.1 621.1,339.4 621.5,338.3L661.1,338.3C675.6,338.3 687.5,350.3 687.5,365.1L687.5,661C687.5,675.8 675.6,687.8 661.1,687.8L546.5,687.8C532,687.8 520.1,675.8 520.1,661L520.1,592.7ZM525.6,592.3C545.1,590.7 564.1,585.6 581.8,577.3C583.7,578.8 585.6,580.3 587.6,581.6C580.1,586 572.2,589.8 564,592.9C568.3,601.3 573.3,609.4 578.9,617C604.1,609.4 627.7,597.5 648.8,581.9C654.6,522.4 639,470.9 607.8,425.1C589.7,416.8 570.5,410.9 550.9,407.6C548.2,412.4 545.7,417.4 543.6,422.4C537.6,421.5 531.6,420.9 525.6,420.5L525.6,365.2C525.6,353.5 535.2,344 546.9,344L660.7,344C672.4,344 682,353.5 682,365.2L682,659.7C682,671.4 672.4,680.8 660.7,680.8L660.7,680.8L660.7,680.8L546.9,680.8C535.2,680.8 525.6,671.4 525.6,659.7L525.6,592.3ZM441.3,522.8C441.3,538 452.5,550.4 466.2,550.4C480,550.4 490.7,538 491,522.8C491.2,507.6 480.1,495.1 466.1,495.1C452.1,495.1 441.3,507.6 441.3,522.8L441.3,522.8L441.3,522.8ZM533,522.8C533,538 544.2,550.4 557.8,550.4C571.7,550.4 582.4,538 582.7,522.8C582.9,507.6 571.8,495.1 557.8,495.1C543.9,495.1 533,507.6 533,522.8L533,522.8L533,522.8ZM423.8,360.2C426.6,360.2 428.9,357.9 428.9,355.1C428.9,352.3 426.6,350 423.8,350C421,350 418.7,352.3 418.7,355.1C418.7,357.9 421,360.2 423.8,360.2L423.8,360.2L423.8,360.2ZM498.5,420.5C492.4,420.9 486.4,421.5 480.4,422.4C478.2,417.4 475.8,412.4 473.1,407.6C453.4,411 434.2,416.9 416.1,425.2C380.1,478.6 370.3,530.6 375.2,581.9C396.3,597.5 419.9,609.4 445,617C450.7,609.4 455.7,601.3 460,592.9C451.8,589.8 443.9,586 436.4,581.6C438.4,580.2 440.3,578.7 442.2,577.3C459.9,585.6 479,590.7 498.5,592.3L498.5,668.6C498.5,672.7 495.1,676.1 491,676.1L356.6,676.1C352.5,676.1 349.1,672.7 349.1,668.6L349.1,352.4C349.1,348.3 352.5,344.9 356.6,344.9L356.6,344.9L356.6,344.9L491,344.9C495.1,344.9 498.5,348.3 498.5,352.4L498.5,420.5L498.5,420.5ZM504.8,420.2C504.8,420.2 504.8,420.2 504.8,420.2L504.8,420.2L504.8,592.7C504.8,592.7 504.8,592.7 504.8,592.7L504.8,674.6C504.8,681.2 499.4,686.6 492.8,686.6L354.8,686.6C348.2,686.6 342.8,681.2 342.8,674.6L342.8,350C342.8,343.4 348.2,338 354.8,338L354.8,338L354.8,338L492.8,338C499.4,338 504.8,343.4 504.8,350L504.8,420.2L504.8,420.2ZM622.6,349.4L585.2,349.4C581.9,349.4 579.2,352.1 579.2,355.4C579.2,358.7 581.9,361.4 585.2,361.4L622.6,361.4C625.9,361.4 628.6,358.7 628.6,355.4C628.5,352.1 625.9,349.4 622.6,349.4L622.6,349.4Z"
android:fillColor="#FFFFFF"
android:fillType="evenOdd"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1024dp"
android:height="1024dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#3AB8BA"
android:fillAlpha="0"/>
<path
android:pathData="M520.1,592.7C520,592.7 519.9,592.7 519.8,592.7L519.8,592.7L519.8,592.7L519.8,420.2C519.9,420.2 520,420.2 520.1,420.2L520.1,365.1C520.1,350.3 531.9,338.3 546.5,338.3L585.3,338.3C585.7,339.3 586.7,340.1 587.9,340.1L618.9,340.1L618.9,340.1L618.9,340.1C620.1,340.1 621.1,339.4 621.5,338.3L661.1,338.3C675.6,338.3 687.5,350.3 687.5,365.1L687.5,661C687.5,675.8 675.6,687.8 661.1,687.8L546.5,687.8C532,687.8 520.1,675.8 520.1,661L520.1,592.7ZM525.6,592.3C545.1,590.7 564.1,585.6 581.8,577.3C583.7,578.8 585.6,580.3 587.6,581.6C580.1,586 572.2,589.8 564,592.9C568.3,601.3 573.3,609.4 578.9,617C604.1,609.4 627.7,597.5 648.8,581.9C654.6,522.4 639,470.9 607.8,425.1C589.7,416.8 570.5,410.9 550.9,407.6C548.2,412.4 545.7,417.4 543.6,422.4C537.6,421.5 531.6,420.9 525.6,420.5L525.6,365.2C525.6,353.5 535.2,344 546.9,344L660.7,344C672.4,344 682,353.5 682,365.2L682,659.7C682,671.4 672.4,680.8 660.7,680.8L660.7,680.8L660.7,680.8L546.9,680.8C535.2,680.8 525.6,671.4 525.6,659.7L525.6,592.3ZM441.3,522.8C441.3,538 452.5,550.4 466.2,550.4C480,550.4 490.7,538 491,522.8C491.2,507.6 480.1,495.1 466.1,495.1C452.1,495.1 441.3,507.6 441.3,522.8L441.3,522.8L441.3,522.8ZM533,522.8C533,538 544.2,550.4 557.8,550.4C571.7,550.4 582.4,538 582.7,522.8C582.9,507.6 571.8,495.1 557.8,495.1C543.9,495.1 533,507.6 533,522.8L533,522.8L533,522.8ZM423.8,360.2C426.6,360.2 428.9,357.9 428.9,355.1C428.9,352.3 426.6,350 423.8,350C421,350 418.7,352.3 418.7,355.1C418.7,357.9 421,360.2 423.8,360.2L423.8,360.2L423.8,360.2ZM498.5,420.5C492.4,420.9 486.4,421.5 480.4,422.4C478.2,417.4 475.8,412.4 473.1,407.6C453.4,411 434.2,416.9 416.1,425.2C380.1,478.6 370.3,530.6 375.2,581.9C396.3,597.5 419.9,609.4 445,617C450.7,609.4 455.7,601.3 460,592.9C451.8,589.8 443.9,586 436.4,581.6C438.4,580.2 440.3,578.7 442.2,577.3C459.9,585.6 479,590.7 498.5,592.3L498.5,668.6C498.5,672.7 495.1,676.1 491,676.1L356.6,676.1C352.5,676.1 349.1,672.7 349.1,668.6L349.1,352.4C349.1,348.3 352.5,344.9 356.6,344.9L356.6,344.9L356.6,344.9L491,344.9C495.1,344.9 498.5,348.3 498.5,352.4L498.5,420.5L498.5,420.5ZM504.8,420.2C504.8,420.2 504.8,420.2 504.8,420.2L504.8,420.2L504.8,592.7C504.8,592.7 504.8,592.7 504.8,592.7L504.8,674.6C504.8,681.2 499.4,686.6 492.8,686.6L354.8,686.6C348.2,686.6 342.8,681.2 342.8,674.6L342.8,350C342.8,343.4 348.2,338 354.8,338L354.8,338L354.8,338L492.8,338C499.4,338 504.8,343.4 504.8,350L504.8,420.2L504.8,420.2ZM622.6,349.4L585.2,349.4C581.9,349.4 579.2,352.1 579.2,355.4C579.2,358.7 581.9,361.4 585.2,361.4L622.6,361.4C625.9,361.4 628.6,358.7 628.6,355.4C628.5,352.1 625.9,349.4 622.6,349.4L622.6,349.4Z"
android:fillColor="#FFFFFF"
android:fillType="evenOdd"/>
</group>
</vector>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="vendetta_brand">#FF3AB8BA</color>
</resources>

View File

@ -0,0 +1,63 @@
<resources>
<string name="app_name">Vendetta Manager</string>
<string name="msg_cleared_cache">Cache cleared successfully</string>
<string name="group_download">Download APKs</string>
<string name="group_patch">Patching</string>
<string name="group_installing">Installing</string>
<string name="step_dl_base">Downloading base apk</string>
<string name="step_dl_lib">Downloading libraries apk</string>
<string name="step_dl_lang">Downloading language apk</string>
<string name="step_dl_res">Downloading resources apk</string>
<string name="step_dl_vd">Downloading Vendetta module</string>
<string name="step_change_icon">Changing app icon</string>
<string name="step_patch_manifests">Patching app manifests</string>
<string name="step_add_vd">Injecting Vendetta</string>
<string name="step_signing">Signing APKs</string>
<string name="step_installing">Installing APKs</string>
<string name="installer_success">Installed successfully</string>
<string name="installer_aborted">Install canceled</string>
<string name="installer_failed">Failed to install: %d</string>
<string name="status_ongoing">Ongoing</string>
<string name="status_successful">Successful</string>
<string name="status_fail">Failed</string>
<string name="status_queued">Queued</string>
<string name="action_collapse">Collapse</string>
<string name="action_expand">Expand</string>
<string name="action_back">Go back</string>
<string name="action_copy_logs">Copy logs</string>
<string name="action_clear_cache">Clear cache</string>
<string name="action_confirm">Confirm</string>
<string name="action_update">Start update</string>
<string name="action_install">Install</string>
<string name="installer_cached">Cached</string>
<string name="title_installer">Installer</string>
<string name="title_home">Home</string>
<string name="title_settings">Settings</string>
<string name="title_update">Update available!</string>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_dynamic_color">Dynamic color</string>
<string name="settings_dynamic_color_description">Only available on Android 12 and up</string>
<string name="settings_theme">Theme</string>
<string name="settings_advanced">Advanced</string>
<string name="settings_app_name">App name</string>
<string name="settings_app_icon">Replace app icon</string>
<string name="settings_app_icon_description">Uses the Vendetta icon instead of Discord\'s</string>
<string name="update_description">Vendetta Manager version %1$s is now available!</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.VendettaManager" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -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)
}
}

19
build.gradle.kts Normal file
View File

@ -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<Copy>().all {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
allprojects {
repositories {
google()
mavenCentral()
maven("https://maven.aliucord.com/snapshots")
maven("https://jitpack.io")
}
}

23
gradle.properties Normal file
View File

@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

185
gradlew vendored Normal file
View File

@ -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" "$@"

89
gradlew.bat vendored Normal file
View File

@ -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

10
settings.gradle.kts Normal file
View File

@ -0,0 +1,10 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "Vendetta Manager"
include(":app")