diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json new file mode 100644 index 0000000000..d85ea456d1 --- /dev/null +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json @@ -0,0 +1,485 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "6dfe67cc094bf524a3127f555a885097", + "entities": [ + { + "tableName": "patch_bundles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, `use_prereleases` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionHash", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releasedAt", + "columnName": "released_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "usePrereleases", + "columnName": "use_prereleases", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "patch_selections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchBundle", + "columnName": "patch_bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_patch_selections_patch_bundle_package_name", + "unique": true, + "columnNames": [ + "patch_bundle", + "package_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)" + } + ], + "foreignKeys": [ + { + "table": "patch_bundles", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "patch_bundle" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "selected_patches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`selection` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`selection`, `patch_name`), FOREIGN KEY(`selection`) REFERENCES `patch_selections`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "selection", + "columnName": "selection", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "selection", + "patch_name" + ] + }, + "foreignKeys": [ + { + "table": "patch_selections", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "selection" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "downloaded_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "last_used", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "version" + ] + } + }, + { + "tableName": "installed_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`current_package_name` TEXT NOT NULL, `original_package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `install_type` TEXT NOT NULL, PRIMARY KEY(`current_package_name`))", + "fields": [ + { + "fieldPath": "currentPackageName", + "columnName": "current_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalPackageName", + "columnName": "original_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installType", + "columnName": "install_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "current_package_name" + ] + } + }, + { + "tableName": "applied_patch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundle", + "columnName": "bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "bundle", + "patch_name" + ] + }, + "foreignKeys": [ + { + "table": "installed_app", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "package_name" + ], + "referencedColumns": [ + "current_package_name" + ] + } + ] + }, + { + "tableName": "installed_patch_bundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle_uid` INTEGER NOT NULL, `bundle_name` TEXT NOT NULL, `bundle_version` TEXT, PRIMARY KEY(`package_name`, `bundle_uid`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundleUid", + "columnName": "bundle_uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bundleName", + "columnName": "bundle_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundleVersion", + "columnName": "bundle_version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "bundle_uid" + ] + }, + "foreignKeys": [ + { + "table": "installed_app", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "package_name" + ], + "referencedColumns": [ + "current_package_name" + ] + } + ] + }, + { + "tableName": "option_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchBundle", + "columnName": "patch_bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_option_groups_patch_bundle_package_name", + "unique": true, + "columnNames": [ + "patch_bundle", + "package_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)" + } + ], + "foreignKeys": [ + { + "table": "patch_bundles", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "patch_bundle" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "options", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group", + "patch_name", + "key" + ] + }, + "foreignKeys": [ + { + "table": "option_groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "downloaders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, `use_prereleases` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionHash", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releasedAt", + "columnName": "released_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "usePrereleases", + "columnName": "use_prereleases", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6dfe67cc094bf524a3127f555a885097')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 59bc5c2f01..93bb7c466e 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -26,7 +26,7 @@ import kotlin.random.Random @Database( entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, InstalledPatchBundle::class, OptionGroup::class, Option::class, DownloaderEntity::class], - version = 5, + version = 6, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -36,7 +36,8 @@ import kotlin.random.Random spec = AppDatabase.DeleteTrustedDownloaders::class ), AutoMigration(from = 3, to = 4), - AutoMigration(from = 4, to = 5) + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt index 85dd7ffc3f..08d7d75008 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt @@ -23,7 +23,7 @@ interface PatchBundleDao { @Query("DELETE FROM patch_bundles WHERE uid = :uid") suspend fun remove(uid: Int) - @Query("SELECT name, version, auto_update, source, released_at FROM patch_bundles WHERE uid = :uid") + @Query("SELECT name, version, auto_update, source, released_at, use_prereleases FROM patch_bundles WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? @Upsert diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt index 7c4b01177e..fea4528413 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt @@ -12,4 +12,5 @@ data class PatchBundleEntity( @ColumnInfo(name = "source") val source: Source, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null, + @ColumnInfo(name = "use_prereleases", defaultValue = "0") val usePrereleases: Boolean = false, ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt index 344273d729..8ee40b5dc3 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt @@ -26,7 +26,7 @@ interface DownloaderDao { @Query("DELETE FROM downloaders WHERE uid = :uid") suspend fun remove(uid: Int) - @Query("SELECT name, version, auto_update, source, released_at FROM downloaders WHERE uid = :uid") + @Query("SELECT name, version, auto_update, source, released_at, use_prereleases FROM downloaders WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? @Upsert diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt index a0d558002d..e90209d46b 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt @@ -11,5 +11,6 @@ data class DownloaderEntity( @ColumnInfo(name = "version") val versionHash: String? = null, @ColumnInfo(name = "source") val source: Source, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, - @ColumnInfo(name = "released_at") val releasedAt: Long? = null + @ColumnInfo(name = "released_at") val releasedAt: Long? = null, + @ColumnInfo(name = "use_prereleases", defaultValue = "0") val usePrereleases: Boolean = false ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt index 060ed92674..c87d71b6b1 100644 --- a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt @@ -36,4 +36,5 @@ data class SourceProperties( @ColumnInfo(name = "source") val source: Source, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null, + @ColumnInfo(name = "use_prereleases") val usePrereleases: Boolean = false, ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index 6ea6dc0c26..035a08906a 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -182,6 +182,7 @@ abstract class SourceManager( source = new.source, autoUpdate = new.autoUpdate, releasedAt = new.releasedAt, + usePrereleases = new.usePrereleases ) ) ) @@ -263,6 +264,15 @@ abstract class SourceManager( state.copy(sources = state.sources.toMutableMap().also { it[uid] = newSrc }) } + suspend fun RemoteSource.setUsePrereleases(value: Boolean) = + dispatchAction("Set use prereleases ($name, $value)") { state -> + updateDb(uid) { it.copy(usePrereleases = value) } + val newSrc = state.sources[uid]?.asRemoteOrNull?.copy(usePrereleases = value) + ?: return@dispatchAction state + + state.copy(sources = state.sources.toMutableMap().also { it[uid] = newSrc }) + } + suspend fun update( vararg sources: RemoteSource, showToast: Boolean = false, diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 1c39aee3f2..0960dd51d4 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -81,6 +81,7 @@ class DownloaderRepository( file, SourceInfo.API.SENTINEL, autoUpdate, + usePrereleases, loader ) { getDownloaderUpdate() } @@ -93,6 +94,7 @@ class DownloaderRepository( file, source.url.toString(), autoUpdate, + usePrereleases, loader ) } @@ -107,7 +109,8 @@ class DownloaderRepository( versionHash = props.versionHash, source = props.source, autoUpdate = props.autoUpdate, - releasedAt = props.releasedAt + releasedAt = props.releasedAt, + usePrereleases = props.usePrereleases ) override fun realNameOf(loaded: DownloaderPackage) = loaded.name diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt index 4517c2725e..c2a032ae2e 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt @@ -79,6 +79,7 @@ class PatchBundleRepository( file, SourceInfo.API.SENTINEL, autoUpdate, + usePrereleases, PatchBundleLoader ) { getPatchesUpdate() } @@ -91,6 +92,7 @@ class PatchBundleRepository( file, source.url.toString(), autoUpdate, + usePrereleases, PatchBundleLoader ) } @@ -105,7 +107,8 @@ class PatchBundleRepository( versionHash = props.versionHash, source = props.source, autoUpdate = props.autoUpdate, - releasedAt = props.releasedAt + releasedAt = props.releasedAt, + usePrereleases = props.usePrereleases ) override fun realNameOf(loaded: PatchBundle) = loaded.manifestAttributes?.name diff --git a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt index cc3246712e..2fc2fab6b0 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt @@ -1,5 +1,7 @@ package app.revanced.manager.domain.sources +import android.app.Application +import app.revanced.manager.R import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset @@ -7,6 +9,7 @@ import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.utils.APIResponse import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.patcher.patch.PatchBundle +import app.revanced.manager.ui.component.sources.GithubRelease import io.ktor.client.request.url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -14,6 +17,9 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant typealias RemotePatchBundle = RemoteSource typealias JsonPatchBundle = JsonSource @@ -28,23 +34,26 @@ sealed class RemoteSource( file: File, val endpoint: String, val autoUpdate: Boolean, + val usePrereleases: Boolean, loader: Loader ) : Source(name, uid, error, file, loader), KoinComponent { data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) protected val http: HttpService by inject() + protected val app: Application by inject() protected abstract suspend fun getLatestInfo(): ReVancedAsset abstract fun copy( error: Throwable? = this.error, name: String = this.name, autoUpdate: Boolean = this.autoUpdate, + usePrereleases: Boolean = this.usePrereleases, versionHash: String? = this.versionHash, releasedAt: LocalDateTime? = this.releasedAt ): RemoteSource override fun copy(error: Throwable?, name: String): RemoteSource = - copy(error, name, this.autoUpdate, this.versionHash, this.releasedAt) + copy(error, name, this.autoUpdate, this.usePrereleases, this.versionHash, this.releasedAt) private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { outputStream().use { @@ -81,18 +90,59 @@ class JsonSource( file: File, endpoint: String, autoUpdate: Boolean, + usePrereleases: Boolean, loader: Loader -) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { +) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, usePrereleases, loader) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { - http.request { - url(endpoint) - }.getOrThrow() + if (!endpoint.endsWith(".json")) { + val githubMatch = Regex("^https://github\\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/(.+)$").find(endpoint) + if (githubMatch != null) { + val owner = githubMatch.groupValues[1] + val repo = githubMatch.groupValues[2] + val tag = githubMatch.groupValues[3] + val currentFilename = githubMatch.groupValues[4] + val extension = currentFilename.substringAfterLast('.', "") + + try { + val releases = http.request> { + url("https://api.github.com/repos/$owner/$repo/releases") + }.getOrThrow() + + val filteredReleases = if (usePrereleases) releases else releases.filter { !it.prerelease } + val latestRelease = filteredReleases.firstOrNull() + ?: error(app.getString(R.string.github_release_none_found)) + + val targetAsset = latestRelease.assets.firstOrNull { it.name.endsWith(".$extension") } + ?: error(app.getString(R.string.github_asset_none_found)) + + val date = latestRelease.createdAt?.let { Instant.parse(it).toLocalDateTime(TimeZone.UTC) } + + return@withContext ReVancedAsset( + downloadUrl = targetAsset.browserDownloadUrl, + version = targetAsset.name, + description = latestRelease.name ?: app.getString(R.string.github_external_asset), + createdAt = date ?: releasedAt ?: LocalDateTime(1970, 1, 1, 0, 0, 0) + ) + } catch (_: Exception) { + // Fallback to boilerplate + } + } + + return@withContext ReVancedAsset( + downloadUrl = endpoint, + version = endpoint.substringAfterLast('/'), + description = app.getString(R.string.github_external_asset), + createdAt = releasedAt ?: LocalDateTime(1970, 1, 1, 0, 0, 0) + ) + } + http.request { url(endpoint) }.getOrThrow() } override fun copy( error: Throwable?, name: String, autoUpdate: Boolean, + usePrereleases: Boolean, versionHash: String?, releasedAt: LocalDateTime? ) = JsonSource( @@ -104,6 +154,7 @@ class JsonSource( file, endpoint, autoUpdate, + usePrereleases, loader ) } @@ -117,9 +168,10 @@ class APISource( file: File, endpoint: String, autoUpdate: Boolean, + usePrereleases: Boolean = false, loader: Loader, private val getUpdate: suspend ReVancedAPI.() -> APIResponse -) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { +) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, usePrereleases, loader) { private val api: ReVancedAPI by inject() override suspend fun getLatestInfo() = api.getUpdate().getOrThrow() @@ -127,6 +179,7 @@ class APISource( error: Throwable?, name: String, autoUpdate: Boolean, + usePrereleases: Boolean, versionHash: String?, releasedAt: LocalDateTime? ) = APISource( @@ -138,6 +191,7 @@ class APISource( file, endpoint, autoUpdate, + usePrereleases, loader, getUpdate ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/SurfaceChip.kt b/app/src/main/java/app/revanced/manager/ui/component/SurfaceChip.kt index f9936d3741..f552c7b376 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/SurfaceChip.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/SurfaceChip.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R @@ -16,12 +17,14 @@ import com.eygraber.compose.placeholder.placeholder @Composable fun SurfaceChip( - text: String? = null + text: String? = null, + color: Color? = null, + contentColor: Color? = null ) { Surface( shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + color = color ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = contentColor ?: MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.height(24.dp) ) { Box( diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt index a9631cce56..a48cfdfe87 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt @@ -49,17 +49,39 @@ fun SafeguardBooleanItem( onValueChange: ((Boolean) -> Unit)? = null ) { val value by preference.getAsState() - var showSafeguardWarning by rememberSaveable { - mutableStateOf(false) - } val update = onValueChange ?: { coroutineScope.launch { preference.update(it) } } + SafeguardBooleanItem( + modifier = modifier, + value = value, + default = preference.default, + headline = headline, + description = description, + dialogTitle = dialogTitle, + confirmationText = confirmationText, + onValueChange = { update(it) } + ) +} + +@Composable +fun SafeguardBooleanItem( + modifier: Modifier = Modifier, + value: Boolean, + default: Boolean = false, + @StringRes headline: Int, + description: String, + @StringRes dialogTitle: Int, + @StringRes confirmationText: Int, + onValueChange: (Boolean) -> Unit +) { + var showSafeguardWarning by rememberSaveable { mutableStateOf(false) } + if (showSafeguardWarning) { ConfirmDialog( onDismiss = { showSafeguardWarning = false }, onConfirm = { - update(!value) + onValueChange(!value) showSafeguardWarning = false }, title = stringResource(id = dialogTitle), @@ -72,10 +94,10 @@ fun SafeguardBooleanItem( modifier = modifier, value = value, onValueChange = { - if (it != preference.default) { + if (it != default) { showSafeguardWarning = true } else { - update(it) + onValueChange(it) } }, headline = headline, diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt index 9dac923a4c..b5753da1c3 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt @@ -7,28 +7,47 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Box 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.Topic +import androidx.compose.material.icons.outlined.AttachFile +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.revanced.manager.R +import app.revanced.manager.network.service.HttpService +import app.revanced.manager.network.utils.APIResponse import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.TooltipIconButton import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticRadioButton +import app.revanced.manager.ui.screen.TagValue import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.BIN_MIMETYPE +import app.revanced.manager.util.PGP_MIMETYPE +import app.revanced.manager.util.relativeTime import app.revanced.manager.util.transparentListItemColors +import io.ktor.client.request.url +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject +import kotlin.time.Instant private enum class SourceType { Local, @@ -58,6 +77,22 @@ enum class ImportSourceDialogStrings( ), } +@Serializable +data class GithubRelease( + val name: String? = null, + @SerialName("tag_name") val tagName: String, + val prerelease: Boolean, + val assets: List = emptyList(), + @SerialName("created_at") val createdAt: String? = null +) + +@Serializable +data class GithubAsset( + val name: String, + @SerialName("content_type") val contentType: String? = null, + @SerialName("browser_download_url") val browserDownloadUrl: String +) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImportSourceDialog( @@ -72,42 +107,74 @@ fun ImportSourceDialog( var remoteUrl by rememberSaveable { mutableStateOf("") } var autoUpdate by rememberSaveable { mutableStateOf(true) } + val githubMatch by + remember(remoteUrl) { + derivedStateOf { + Regex("^https://github\\.com/([^/]+)/([^/]+)/?$").find(remoteUrl.trim()) + } + } + val isGithubRepoUrl by + remember(sourceType, githubMatch) { + derivedStateOf { sourceType == SourceType.Remote && githubMatch != null } + } + + var selectedGithubAssetUrl by rememberSaveable { mutableStateOf(null) } + val fileActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { local = it } } fun launchFileActivity() { - when(strings) { + when (strings) { ImportSourceDialogStrings.PATCHES -> fileActivityLauncher.launch(BIN_MIMETYPE) ImportSourceDialogStrings.DOWNLOADERS -> fileActivityLauncher.launch(APK_MIMETYPE) } } - val steps = listOf<@Composable () -> Unit>( - { - SelectSourceTypeStep(strings, sourceType) { selectedType -> - sourceType = selectedType - } - }, - { - ImportSourceStep( - strings, - sourceType, - local, - remoteUrl, - autoUpdate, - ::launchFileActivity, - { remoteUrl = it }, - { autoUpdate = it } + val steps = mutableListOf<@Composable () -> Unit>() + + steps.add { + SelectSourceTypeStep(strings, sourceType) { selectedType -> sourceType = selectedType } + } + + steps.add { + ImportSourceStep( + strings, + sourceType, + local, + remoteUrl, + autoUpdate, + ::launchFileActivity, + { remoteUrl = it }, + { autoUpdate = it } + ) + } + + if (isGithubRepoUrl) { + steps.add { + GithubReleaseStep( + owner = githubMatch!!.groupValues[1], + repo = githubMatch!!.groupValues[2], + strings = strings, + onAssetSelected = { selectedGithubAssetUrl = it } ) } - ) + } - val inputsAreValid by remember { + val inputsAreValid by remember( + currentStep, + sourceType, + local, + remoteUrl, + isGithubRepoUrl, + selectedGithubAssetUrl + ) { derivedStateOf { - (sourceType == SourceType.Local && local != null) || - (sourceType == SourceType.Remote && remoteUrl.isNotEmpty()) + if (currentStep < steps.lastIndex) return@derivedStateOf true + if (sourceType == SourceType.Local) return@derivedStateOf local != null + if (isGithubRepoUrl) return@derivedStateOf selectedGithubAssetUrl != null + remoteUrl.isNotEmpty() } } @@ -117,7 +184,7 @@ fun ImportSourceDialog( Text(stringResource(strings.title)) }, text = { - steps[currentStep]() + if (currentStep in steps.indices) steps[currentStep]() }, confirmButton = { if (currentStep == steps.lastIndex) { @@ -126,7 +193,11 @@ fun ImportSourceDialog( onClick = { when (sourceType) { SourceType.Local -> local?.let(onLocalSubmit) - SourceType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate) + SourceType.Remote -> onRemoteSubmit( + if (isGithubRepoUrl) selectedGithubAssetUrl!! + else remoteUrl, + autoUpdate + ) } }, shapes = ButtonDefaults.shapes() @@ -134,9 +205,11 @@ fun ImportSourceDialog( Text(stringResource(R.string.add)) } } else { - TextButton(onClick = { currentStep++ }, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.next)) - } + TextButton( + enabled = inputsAreValid, + onClick = { currentStep++ }, + shapes = ButtonDefaults.shapes() + ) { Text(stringResource(R.string.next)) } } }, dismissButton = { @@ -274,4 +347,88 @@ private fun ImportSourceStep( } } } +} + +@Composable +private fun GithubReleaseStep( + owner: String, + repo: String, + strings: ImportSourceDialogStrings, + onAssetSelected: (String?) -> Unit +) { + val httpService: HttpService = koinInject() + var releases by remember { mutableStateOf?>(null) } + var error by remember { mutableStateOf(null) } + val fetchFailedMessage = stringResource(R.string.github_releases_fetch_failed) + + LaunchedEffect(owner, repo) { + val response = + httpService.request> { + url("https://api.github.com/repos/$owner/$repo/releases") + } + if (response is APIResponse.Success) { + releases = response.data + } else { + error = fetchFailedMessage + } + } + + Column { + if (error != null) { + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(24.dp) + ) + } else if (releases == null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + val latestRelease = releases!!.filter { !it.prerelease } + val bundle = latestRelease.firstOrNull()?.let { release -> + when (strings) { + ImportSourceDialogStrings.PATCHES -> release.assets.firstOrNull { + it.name.endsWith(".rvp") && it.contentType != PGP_MIMETYPE + } + ImportSourceDialogStrings.DOWNLOADERS -> release.assets.firstOrNull { + it.name.endsWith(".apk") && it.contentType == APK_MIMETYPE + } + }?.let { asset -> release to asset } + } + + SideEffect { bundle?.let { (_, asset) -> onAssetSelected(asset.browserDownloadUrl) } } + + Column(Modifier.padding(horizontal = 24.dp)) { when { + latestRelease.isEmpty() -> Text(stringResource(R.string.github_release_none_found)) + bundle == null -> Text(stringResource(R.string.github_asset_none_found)) + else -> { + val (release, asset) = bundle + TagValue( + icon = Icons.Outlined.AttachFile, + title = "File", + value = asset.name + ) + TagValue( + icon = Icons.Outlined.Sell, + title = "Version", + value = release.name ?: release.tagName + ) + TagValue( + icon = Icons.Outlined.Update, + title = "Updated", + value = release.createdAt?.let { + Instant.parse(it).toLocalDateTime(TimeZone.UTC) + .relativeTime(LocalContext.current) + } ?: "Unknown" + ) + } + } } + } + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt index 26b4c9f7d9..2a8078adf2 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt @@ -285,6 +285,18 @@ fun BundleInformationScreen( confirmationText = R.string.prereleases_warning, onValueChange = viewModel::updateUsePrereleases ) + } else if (autoUpdate != null) { + SafeguardBooleanItem( + value = src.asRemoteOrNull?.usePrereleases ?: false, + headline = R.string.patches_prereleases, + description = stringResource( + R.string.patches_prereleases_description, + src.name + ), + dialogTitle = R.string.prerelease_title, + confirmationText = R.string.prereleases_warning, + onValueChange = viewModel::setUsePrereleases + ) } endpoint?.takeUnless { src.isDefault }?.let { url -> @@ -383,7 +395,7 @@ fun BundleInformationScreen( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun TagValue( +internal fun TagValue( icon: ImageVector, title: String, value: String, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt index 1c9efbe4d9..714cc2d446 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt @@ -213,6 +213,18 @@ fun DownloaderInfoScreen( confirmationText = R.string.prereleases_warning, onValueChange = viewModel::updateUsePrereleases ) + } else if (remote != null) { + SafeguardBooleanItem( + value = remote.usePrereleases, + headline = R.string.downloader_prereleases, + description = stringResource( + R.string.downloader_prereleases_description, + source.name + ), + dialogTitle = R.string.prerelease_title, + confirmationText = R.string.prereleases_warning, + onValueChange = { viewModel.setExternalSourceUsePrereleases(remote, it) } + ) } remote?.endpoint?.takeUnless { source.isDefault }?.let { url -> diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt index 3b7ca374cc..30c25b1c09 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt @@ -38,4 +38,11 @@ class BundleInformationViewModel(uid: Int) : ViewModel(), KoinComponent { prefs.usePatchesPrereleases.update(value) refresh() } + + fun setUsePrereleases(value: Boolean) = viewModelScope.launch { + bundle.first()?.asRemoteOrNull?.let { + patchBundleRepository.run { it.setUsePrereleases(value) } + refresh() + } + } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index 63d20cd7b0..a7f8e4d20f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -116,4 +116,11 @@ class DownloadsViewModel( src.setAutoUpdate(value) } } + + fun setExternalSourceUsePrereleases(src: RemoteSource, value: Boolean) = viewModelScope.launch { + with(downloaderRepository) { + src.setUsePrereleases(value) + } + updateDownloader(src) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/Constants.kt b/app/src/main/java/app/revanced/manager/util/Constants.kt index 000da463f6..804fbc20da 100644 --- a/app/src/main/java/app/revanced/manager/util/Constants.kt +++ b/app/src/main/java/app/revanced/manager/util/Constants.kt @@ -5,4 +5,5 @@ const val tag = "ReVanced Manager" const val JAR_MIMETYPE = "application/java-archive" const val APK_MIMETYPE = "application/vnd.android.package-archive" const val JSON_MIMETYPE = "application/json" -const val BIN_MIMETYPE = "application/octet-stream" \ No newline at end of file +const val BIN_MIMETYPE = "application/octet-stream" +const val PGP_MIMETYPE = "application/pgp-keys" \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7ae10283e..f23f580e0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -580,6 +580,11 @@ It’s only compatible with these versions: %2$s Use pre-releases Use pre-release versions of %s Patches URL + Failed to fetch releases + No suitable release found + No suitable asset found in latest release + External GitHub asset + Failed to update These patches aren’t compatible with the selected app version: %1$s Tap them for more details.