diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c4f0e5b..493d7daf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,13 +2,22 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("kotlin-parcelize") + kotlin("plugin.serialization") version "1.7.20" } repositories { mavenCentral() maven("https://jitpack.io") google() + maven { + url = uri("https://maven.pkg.github.com/revanced/revanced-patcher") + credentials { + username = (project.findProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")) as String + password = (project.findProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")) as String + } + } } + android { namespace = "app.revanced.manager.compose" compileSdk = 33 @@ -40,24 +49,39 @@ android { buildFeatures.compose = true - composeOptions.kotlinCompilerExtensionVersion = "1.3.2" + composeOptions.kotlinCompilerExtensionVersion = "1.4.0" } dependencies { // AndroidX Core implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.0") implementation("androidx.core:core-splashscreen:1.0.0") implementation("androidx.activity:activity-compose:1.6.1") // Compose - val composeVersion = "1.3.3" + val composeVersion = "1.4.0-alpha05" implementation("androidx.compose.ui:ui:$composeVersion") implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") + // Accompanist + val accompanistVersion = "0.29.1-alpha" + implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion") + //implementation("com.google.accompanist:accompanist-placeholder-material:$accompanistVersion") + implementation("com.google.accompanist:accompanist-drawablepainter:$accompanistVersion") + //implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion") + //implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion") + + // KotlinX + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") + // Material 3 - implementation("androidx.compose.material3:material3:1.0.1") + implementation("androidx.compose.material3:material3:1.1.0-alpha08") + + + // ReVanced + implementation("app.revanced:revanced-patcher:6.4.3") // Koin implementation("io.insert-koin:koin-android:3.3.2") @@ -65,4 +89,12 @@ dependencies { // Compose Navigation implementation("dev.olshevski.navigation:reimagined:1.3.1") + + // Ktor + val ktorVersion = "2.1.3" + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-logging:$ktorVersion") + implementation("io.ktor:ktor-client-okhttp:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef44dc1a..af437b32 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,19 @@ + + + + + + + + + + + + + + - - + + + \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/MainActivity.kt b/app/src/main/java/app/revanced/manager/compose/MainActivity.kt index d0500114..bdc05311 100644 --- a/app/src/main/java/app/revanced/manager/compose/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/compose/MainActivity.kt @@ -4,14 +4,17 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.isSystemInDarkTheme +import app.revanced.manager.compose.domain.manager.PreferencesManager import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import app.revanced.manager.compose.destination.Destination import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme -import dev.olshevski.navigation.reimagined.AnimatedNavHost -import dev.olshevski.navigation.reimagined.NavBackHandler -import dev.olshevski.navigation.reimagined.rememberNavController +import app.revanced.manager.compose.ui.theme.Theme +import dev.olshevski.navigation.reimagined.* +import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { + private val prefs: PreferencesManager by inject() @ExperimentalAnimationApi override fun onCreate(savedInstanceState: Bundle?) { @@ -20,8 +23,8 @@ class MainActivity : ComponentActivity() { installSplashScreen() setContent { ReVancedManagerTheme( - darkTheme = true, // TODO: Implement preferences - dynamicColor = false + darkTheme = prefs.theme == Theme.SYSTEM && isSystemInDarkTheme() || prefs.theme == Theme.DARK, + dynamicColor = prefs.dynamicColor ) { val navController = rememberNavController(startDestination = Destination.Home) diff --git a/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt index 5afdddc9..2a44586f 100644 --- a/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt @@ -1,6 +1,7 @@ package app.revanced.manager.compose import android.app.Application +import app.revanced.manager.compose.di.* import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -10,7 +11,13 @@ class ManagerApplication: Application() { startKoin { androidContext(this@ManagerApplication) - modules(emptyList()) // TODO: Add modules + modules( + httpModule, + preferencesModule, + repositoryModule, + serviceModule, + viewModelModule + ) } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/HttpModule.kt b/app/src/main/java/app/revanced/manager/compose/di/HttpModule.kt new file mode 100644 index 00000000..c67a0369 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/di/HttpModule.kt @@ -0,0 +1,51 @@ +package app.revanced.manager.compose.di + +import android.content.Context +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import okhttp3.Cache +import okhttp3.Dns +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import java.net.Inet4Address +import java.net.InetAddress + +val httpModule = module { + fun provideHttpClient(context: Context, json: Json) = HttpClient(OkHttp) { + engine { + config { + dns(object : Dns { + override fun lookup(hostname: String): List { + val addresses = Dns.SYSTEM.lookup(hostname) + return if (hostname == "raw.githubusercontent.com") { + addresses.filterIsInstance() + } else { + addresses + } + } + }) + cache(Cache(context.cacheDir.resolve("cache").also { it.mkdirs() }, 1024 * 1024 * 100)) + followRedirects(true) + followSslRedirects(true) + } + } + install(ContentNegotiation) { + json(json) + } + } + + fun provideJson() = Json { + encodeDefaults = true + isLenient = true + ignoreUnknownKeys = true + } + + single { + provideHttpClient(androidContext(), get()) + } + singleOf(::provideJson) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/PreferencesModule.kt b/app/src/main/java/app/revanced/manager/compose/di/PreferencesModule.kt new file mode 100644 index 00000000..83c2887d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/di/PreferencesModule.kt @@ -0,0 +1,14 @@ +package app.revanced.manager.compose.di + +import android.content.Context +import app.revanced.manager.compose.domain.manager.PreferencesManager +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val preferencesModule = module { + fun providePreferences( + context: Context + ) = PreferencesManager(context.getSharedPreferences("preferences", Context.MODE_PRIVATE)) + + singleOf(::providePreferences) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt new file mode 100644 index 00000000..1479824e --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt @@ -0,0 +1,11 @@ +package app.revanced.manager.compose.di + +import app.revanced.manager.compose.domain.repository.ReVancedRepositoryImpl +import app.revanced.manager.compose.network.api.ManagerAPI +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val repositoryModule = module { + singleOf(::ReVancedRepositoryImpl) + singleOf(::ManagerAPI) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt new file mode 100644 index 00000000..3a08842d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt @@ -0,0 +1,20 @@ +package app.revanced.manager.compose.di + +import app.revanced.manager.compose.network.service.HttpService +import app.revanced.manager.compose.network.service.ReVancedService +import app.revanced.manager.compose.network.service.ReVancedServiceImpl +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val serviceModule = module { + fun provideReVancedService( + client: HttpService, + ): ReVancedService { + return ReVancedServiceImpl( + client = client, + ) + } + + single { provideReVancedService(get()) } + singleOf(::HttpService) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/compose/domain/manager/PreferencesManager.kt new file mode 100644 index 00000000..b84c647a --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/manager/PreferencesManager.kt @@ -0,0 +1,16 @@ +package app.revanced.manager.compose.domain.manager + +import android.content.SharedPreferences +import app.revanced.manager.compose.domain.manager.base.BasePreferenceManager +import app.revanced.manager.compose.ui.theme.Theme + +/** + * @author Hyperion Authors, zt64 + */ +class PreferencesManager( + sharedPreferences: SharedPreferences +) : BasePreferenceManager(sharedPreferences) { + var dynamicColor by booleanPreference("dynamic_color", true) + var theme by enumPreference("theme", Theme.SYSTEM) + //var sentry by booleanPreference("sentry", true) +} diff --git a/app/src/main/java/app/revanced/manager/compose/domain/manager/base/BasePreferencesManager.kt b/app/src/main/java/app/revanced/manager/compose/domain/manager/base/BasePreferencesManager.kt new file mode 100644 index 00000000..3fd68582 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/manager/base/BasePreferencesManager.kt @@ -0,0 +1,98 @@ +package app.revanced.manager.compose.domain.manager.base + +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import kotlin.reflect.KProperty + +/** + * @author Hyperion Authors, zt64 + */ +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) + 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) } + 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 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/app/revanced/manager/compose/domain/repository/ReVancedRepository.kt b/app/src/main/java/app/revanced/manager/compose/domain/repository/ReVancedRepository.kt new file mode 100644 index 00000000..9e5aaaf7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/repository/ReVancedRepository.kt @@ -0,0 +1,25 @@ +package app.revanced.manager.compose.domain.repository + +import app.revanced.manager.compose.network.api.PatchesAsset +import app.revanced.manager.compose.network.dto.ReVancedReleases +import app.revanced.manager.compose.network.dto.ReVancedRepositories +import app.revanced.manager.compose.network.service.ReVancedService +import app.revanced.manager.compose.network.utils.APIResponse + +interface ReVancedRepository { + suspend fun getAssets(): APIResponse + + suspend fun getContributors(): APIResponse + + suspend fun findAsset(repo: String, file: String): PatchesAsset +} + +class ReVancedRepositoryImpl( + private val service: ReVancedService +) : ReVancedRepository { + override suspend fun getAssets() = service.getAssets() + + override suspend fun getContributors() = service.getContributors() + + override suspend fun findAsset(repo: String, file: String) = service.findAsset(repo, file) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/installer/service/InstallService.kt b/app/src/main/java/app/revanced/manager/compose/installer/service/InstallService.kt new file mode 100644 index 00000000..2dea3242 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/installer/service/InstallService.kt @@ -0,0 +1,47 @@ +package app.revanced.manager.compose.installer.service + +import android.app.Service +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.os.IBinder + +class InstallService : Service() { + + override fun onStartCommand( + intent: Intent, flags: Int, startId: Int + ): Int { + val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999) + val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + when (extraStatus) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + startActivity(if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_INTENT) + }.apply { + this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + else -> { + sendBroadcast(Intent().apply { + action = APP_INSTALL_ACTION + putExtra(EXTRA_INSTALL_STATUS, extraStatus) + putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage) + }) + } + } + stopSelf() + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + const val APP_INSTALL_ACTION = "APP_INSTALL_ACTION" + + const val EXTRA_INSTALL_STATUS = "EXTRA_INSTALL_STATUS" + const val EXTRA_INSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE" + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/installer/service/UninstallService.kt b/app/src/main/java/app/revanced/manager/compose/installer/service/UninstallService.kt new file mode 100644 index 00000000..5780160b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/installer/service/UninstallService.kt @@ -0,0 +1,42 @@ +package app.revanced.manager.compose.installer.service + +import android.app.Service +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.os.IBinder + +class UninstallService : Service() { + + override fun onStartCommand( + intent: Intent, + flags: Int, + startId: Int + ): Int { + when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + startActivity(if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_INTENT) + }.apply { + this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + else -> { + sendBroadcast(Intent().apply { + action = APP_UNINSTALL_ACTION + }) + } + } + stopSelf() + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + const val APP_UNINSTALL_ACTION = "APP_UNINSTALL_ACTION" + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/installer/utils/PM.kt b/app/src/main/java/app/revanced/manager/compose/installer/utils/PM.kt new file mode 100644 index 00000000..aa84e45b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/installer/utils/PM.kt @@ -0,0 +1,68 @@ +package app.revanced.manager.compose.installer.utils + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import app.revanced.manager.compose.installer.service.InstallService +import app.revanced.manager.compose.installer.service.UninstallService +import java.io.File + +private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable + +object PM { + + fun installApp(apk: File, context: Context) { + val packageInstaller = context.packageManager.packageInstaller + val session = + packageInstaller.openSession(packageInstaller.createSession(sessionParams)) + session.writeApk(apk) + session.commit(context.installIntentSender) + session.close() + } + + fun uninstallPackage(pkg: String, context: Context) { + val packageInstaller = context.packageManager.packageInstaller + packageInstaller.uninstall(pkg, context.uninstallIntentSender) + } +} + +private fun PackageInstaller.Session.writeApk(apk: File) { + apk.inputStream().use { inputStream -> + openWrite(apk.name, 0, apk.length()).use { outputStream -> + inputStream.copyTo(outputStream, byteArraySize) + fsync(outputStream) + } + } +} + +private val intentFlags + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE + else + 0 + +private val sessionParams + get() = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ).apply { + setInstallReason(PackageManager.INSTALL_REASON_USER) + } + +private val Context.installIntentSender + get() = PendingIntent.getService( + this, + 0, + Intent(this, InstallService::class.java), + intentFlags + ).intentSender + +private val Context.uninstallIntentSender + get() = PendingIntent.getService( + this, + 0, + Intent(this, UninstallService::class.java), + intentFlags + ).intentSender \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt b/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt new file mode 100644 index 00000000..ee89d26f --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt @@ -0,0 +1,66 @@ +package app.revanced.manager.compose.network.api + +import android.app.Application +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import app.revanced.manager.compose.domain.repository.ReVancedRepositoryImpl +import app.revanced.manager.compose.util.ghIntegrations +import app.revanced.manager.compose.util.ghPatches +import app.revanced.manager.compose.util.tag +import app.revanced.manager.compose.util.toast +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.util.cio.* +import io.ktor.utils.io.* +import java.io.File + +class ManagerAPI( + private val app: Application, + private val client: HttpClient, + private val revancedRepository: ReVancedRepositoryImpl +) { + var downloadProgress: Float? by mutableStateOf(null) + + private suspend fun downloadAsset(downloadUrl: String, saveLocation: File) { + client.get(downloadUrl) { + onDownload { bytesSentTotal, contentLength -> + downloadProgress = (bytesSentTotal.toFloat() / contentLength.toFloat()) + } + }.bodyAsChannel().copyAndClose(saveLocation.writeChannel()) + downloadProgress = null + } + + suspend fun downloadPatchBundle() { + try { + val downloadUrl = revancedRepository.findAsset(ghPatches, ".jar").downloadUrl + val patchesFile = app.filesDir.resolve("patch-bundles").also { it.mkdirs() } + .resolve("patchbundle.jar") + downloadAsset(downloadUrl, patchesFile) + } catch (e: Exception) { + Log.e(tag, "Failed to download patch bundle", e) + app.toast("Failed to download patch bundle") + } + } + + suspend fun downloadIntegrations() { + try { + val downloadUrl = revancedRepository.findAsset(ghIntegrations, ".apk").downloadUrl + val integrationsFile = app.filesDir.resolve("integrations").also { it.mkdirs() } + .resolve("integrations.apk") + downloadAsset(downloadUrl, integrationsFile) + } catch (e: Exception) { + Log.e(tag, "Failed to download integrations", e) + app.toast("Failed to download integrations") + } + } +} + +data class PatchesAsset( + val downloadUrl: String, val name: String +) + +class MissingAssetException : Exception() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/dto/ReVancedContributors.kt b/app/src/main/java/app/revanced/manager/compose/network/dto/ReVancedContributors.kt new file mode 100644 index 00000000..5d125a4f --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/network/dto/ReVancedContributors.kt @@ -0,0 +1,21 @@ +package app.revanced.manager.compose.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class ReVancedRepositories( + @SerialName("repositories") val repositories: List, +) + +@Serializable +class ReVancedRepository( + @SerialName("name") val name: String, + @SerialName("contributors") val contributors: List, +) + +@Serializable +class ReVancedContributor( + @SerialName("login") val username: String, + @SerialName("avatar_url") val avatarUrl: String, +) diff --git a/app/src/main/java/app/revanced/manager/compose/network/dto/ReVancedReleases.kt b/app/src/main/java/app/revanced/manager/compose/network/dto/ReVancedReleases.kt new file mode 100644 index 00000000..e6b101c8 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/network/dto/ReVancedReleases.kt @@ -0,0 +1,20 @@ +package app.revanced.manager.compose.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class ReVancedReleases( + @SerialName("tools") val tools: List, +) + +@Serializable +class Assets( + @SerialName("repository") val repository: String, + @SerialName("version") val version: String, + @SerialName("timestamp") val timestamp: String, + @SerialName("name") val name: String, + @SerialName("size") val size: String?, + @SerialName("browser_download_url") val downloadUrl: String, + @SerialName("content_type") val content_type: String +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/compose/network/service/HttpService.kt new file mode 100644 index 00000000..a17e1c6f --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/network/service/HttpService.kt @@ -0,0 +1,51 @@ +package app.revanced.manager.compose.network.service + +import android.util.Log +import app.revanced.manager.compose.network.utils.APIError +import app.revanced.manager.compose.network.utils.APIFailure +import app.revanced.manager.compose.network.utils.APIResponse +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +/** + * @author Aliucord Authors, DiamondMiner88 + */ +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 (t: Throwable) { + null + } + + Log.e("ReVanced Manager", "Failed to fetch: API error, http status: ${response.status}, body: $body") + APIResponse.Error(APIError(response.status, body)) + } + } catch (t: Throwable) { + Log.e("ReVanced Manager", "Failed to fetch: error: $t, body: $body") + APIResponse.Failure(APIFailure(t, body)) + } + return response + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt b/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt new file mode 100644 index 00000000..f7c3bf7d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt @@ -0,0 +1,52 @@ +package app.revanced.manager.compose.network.service + +import app.revanced.manager.compose.network.api.MissingAssetException +import app.revanced.manager.compose.network.api.PatchesAsset +import app.revanced.manager.compose.network.dto.ReVancedReleases +import app.revanced.manager.compose.network.dto.ReVancedRepositories +import app.revanced.manager.compose.network.utils.APIResponse +import app.revanced.manager.compose.network.utils.getOrNull +import app.revanced.manager.compose.util.apiURL +import io.ktor.client.request.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface ReVancedService { + suspend fun getAssets(): APIResponse + + suspend fun getContributors(): APIResponse + + suspend fun findAsset(repo: String, file: String): PatchesAsset +} + +class ReVancedServiceImpl( + private val client: HttpService, +) : ReVancedService { + override suspend fun getAssets(): APIResponse { + return withContext(Dispatchers.IO) { + client.request { + url("$apiUrl/tools") + } + } + } + + override suspend fun getContributors(): APIResponse { + return withContext(Dispatchers.IO) { + client.request { + url("$apiUrl/contributors") + } + } + } + + override suspend fun findAsset(repo: String, file: String): PatchesAsset { + val releases = getAssets().getOrNull() ?: throw Exception("Cannot retrieve assets") + val asset = releases.tools.find { asset -> + (asset.name.contains(file) && asset.repository.contains(repo)) + } ?: throw MissingAssetException() + return PatchesAsset(asset.downloadUrl, asset.name) + } + + private companion object { + private const val apiUrl = apiURL + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt b/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt new file mode 100644 index 00000000..7c28e151 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt @@ -0,0 +1,86 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package app.revanced.manager.compose.network.utils + +import io.ktor.http.* + +/** + * @author Aliucord Authors, DiamondMiner88 + */ + +sealed interface APIResponse { + data class Success(val data: T) : 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) + +inline fun APIResponse.fold( + success: (T) -> R, + error: (APIError) -> R, + failure: (APIFailure) -> R +): R { + return when (this) { + is APIResponse.Success -> success(this.data) + is APIResponse.Error -> error(this.error) + is APIResponse.Failure -> failure(this.error) + } +} + +inline fun APIResponse.fold( + success: (T) -> R, + fail: (Error) -> R, +): R { + return when (this) { + is APIResponse.Success -> success(data) + is APIResponse.Error -> fail(error) + is APIResponse.Failure -> fail(error) + } +} + +@Suppress("UNCHECKED_CAST") +inline fun APIResponse.transform(block: (T) -> R): APIResponse { + return if (this !is APIResponse.Success) { + // Error and Failure do not use the generic value + this as APIResponse + } else { + APIResponse.Success(block(data)) + } +} + +inline fun APIResponse.getOrThrow(): T { + return fold( + success = { it }, + fail = { throw it } + ) +} + +inline fun APIResponse.getOrNull(): T? { + return fold( + success = { it }, + fail = { null } + ) +} + +@Suppress("UNCHECKED_CAST") +inline fun APIResponse.chain(block: (T) -> APIResponse): APIResponse { + return if (this !is APIResponse.Success) { + // Error and Failure do not use the generic value + this as APIResponse + } else { + block(data) + } +} + +@Suppress("UNCHECKED_CAST") +inline fun APIResponse.chain(secondary: APIResponse): APIResponse { + return if (secondary is APIResponse.Success) { + secondary + } else { + // Error and Failure do not use the generic value + this as APIResponse + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/theme/Theme.kt b/app/src/main/java/app/revanced/manager/compose/ui/theme/Theme.kt index dfa0ba6c..3e871786 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/theme/Theme.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/theme/Theme.kt @@ -3,11 +3,7 @@ package app.revanced.manager.compose.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.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb @@ -54,4 +50,10 @@ fun ReVancedManagerTheme( typography = Typography, content = content ) +} + +enum class Theme(val displayName: String) { + SYSTEM("System"), + LIGHT("Light"), + DARK("Dark"); } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/util/Constants.kt b/app/src/main/java/app/revanced/manager/compose/util/Constants.kt new file mode 100644 index 00000000..f204fd6e --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/util/Constants.kt @@ -0,0 +1,11 @@ +package app.revanced.manager.compose.util + +private const val team = "revanced" +const val ghOrganization = "https://github.com/$team" +const val ghCli = "$team/revanced-cli" +const val ghPatches = "$team/revanced-patches" +const val ghPatcher = "$team/revanced-patcher" +const val ghManager = "$team/revanced-manager" +const val ghIntegrations = "$team/revanced-integrations" +const val tag = "ReVanced Manager" +const val apiURL = "https://releases.revanced.app" \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/util/Util.kt b/app/src/main/java/app/revanced/manager/compose/util/Util.kt new file mode 100644 index 00000000..a20d0750 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/util/Util.kt @@ -0,0 +1,27 @@ +package app.revanced.manager.compose.util + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException +import android.graphics.drawable.Drawable +import android.widget.Toast +import androidx.core.net.toUri + + +fun Context.openUrl(url: String) { + startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }) +} + +fun Context.loadIcon(string: String): Drawable? { + return try { + packageManager.getApplicationIcon(string) + } catch (e: NameNotFoundException) { + null + } +} + +fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, string, duration).show() +} diff --git a/build.gradle.kts b/build.gradle.kts index 6264a09a..b71d0ff0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ buildscript { plugins { id("com.android.application") version "7.3.1" apply false id("com.android.library") version "7.3.1" apply false - id("org.jetbrains.kotlin.android") version "1.7.20" apply false + id("org.jetbrains.kotlin.android") version "1.8.0" apply false } repositories { google()