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