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..67b7f12686 --- /dev/null +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json @@ -0,0 +1,471 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "da3efac0c33785267e2b14b0b27c93f8", + "entities": [ + { + "tableName": "patch_bundles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `url` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, 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": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releasedAt", + "columnName": "released_at", + "affinity": "INTEGER" + } + ], + "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, `url` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, 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": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releasedAt", + "columnName": "released_at", + "affinity": "INTEGER" + } + ], + "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, 'da3efac0c33785267e2b14b0b27c93f8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 588d528b9e..2fbc82a5d1 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -71,6 +71,7 @@ import app.revanced.manager.util.SupportedLocales import app.revanced.manager.util.deepLinkedComposable import app.revanced.manager.util.navigateSafe import app.revanced.manager.util.popBackStackSafe +import app.revanced.manager.ui.component.FilePickerRequestHost import app.revanced.manager.util.resetListItemColorsCached import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -103,6 +104,7 @@ class MainActivity : AppCompatActivity() { dynamicColor = dynamicColor, pureBlackTheme = pureBlackTheme ) { + FilePickerRequestHost() ReVancedManager(vm) } } 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 093646f01b..6eba3137a9 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 @@ -3,6 +3,7 @@ package app.revanced.manager.data.room import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteTable +import androidx.room.RenameColumn import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec @@ -26,7 +27,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 +37,12 @@ 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, + spec = AppDatabase.RenameSourceToUrl::class + ) ] ) @TypeConverters(Converters::class) @@ -51,6 +57,19 @@ abstract class AppDatabase : RoomDatabase() { @DeleteTable(tableName = "trusted_downloaders") class DeleteTrustedDownloaders : AutoMigrationSpec + // Sources are stored as the URL they are retrieved from. + @RenameColumn( + tableName = "patch_bundles", + fromColumnName = "source", + toColumnName = "url" + ) + @RenameColumn( + tableName = "downloaders", + fromColumnName = "source", + toColumnName = "url" + ) + class RenameSourceToUrl : AutoMigrationSpec + companion object { fun generateUid() = Random.nextInt() } diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt index eed7ddf901..08980dd6c5 100644 --- a/app/src/main/java/app/revanced/manager/data/room/Converters.kt +++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt @@ -2,15 +2,15 @@ package app.revanced.manager.data.room import androidx.room.TypeConverter import app.revanced.manager.data.room.options.Option.SerializedValue -import app.revanced.manager.data.room.sources.Source +import io.ktor.http.Url import java.io.File class Converters { @TypeConverter - fun sourceFromString(value: String) = Source.from(value) + fun urlFromString(value: String) = Url(value) @TypeConverter - fun sourceToString(value: Source) = value.toString() + fun urlToString(value: Url) = value.toString() @TypeConverter fun fileFromString(value: String) = File(value) 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..0a71f55427 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 @@ -2,6 +2,7 @@ package app.revanced.manager.data.room.bundles import androidx.room.* import app.revanced.manager.data.room.sources.SourceProperties +import app.revanced.manager.data.room.sources.SourceUrl @Dao interface PatchBundleDao { @@ -23,9 +24,15 @@ 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, url, released_at FROM patch_bundles WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? + @Query("SELECT uid, url FROM patch_bundles") + suspend fun allUrls(): List + + @Query("UPDATE patch_bundles SET url = :url WHERE uid = :uid") + suspend fun setUrl(uid: Int, url: String) + @Upsert suspend fun upsert(source: PatchBundleEntity) } \ No newline at end of file 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..1917e8510c 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 @@ -1,15 +1,15 @@ package app.revanced.manager.data.room.bundles import androidx.room.* -import app.revanced.manager.data.room.sources.Source import app.revanced.manager.domain.manager.SourceManager +import io.ktor.http.Url @Entity(tableName = "patch_bundles") data class PatchBundleEntity( @PrimaryKey override val uid: Int, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Source, + @ColumnInfo(name = "url") val url: Url, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null, ) : 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..898d733540 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 @@ -5,6 +5,7 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import app.revanced.manager.data.room.sources.SourceProperties +import app.revanced.manager.data.room.sources.SourceUrl @Dao interface DownloaderDao { @@ -26,9 +27,15 @@ 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, url, released_at FROM downloaders WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? + @Query("SELECT uid, url FROM downloaders") + suspend fun allUrls(): List + + @Query("UPDATE downloaders SET url = :url WHERE uid = :uid") + suspend fun setUrl(uid: Int, url: String) + @Upsert suspend fun upsert(source: DownloaderEntity) } \ No newline at end of file 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..85c07107e6 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 @@ -1,15 +1,15 @@ package app.revanced.manager.data.room.downloader import androidx.room.* -import app.revanced.manager.data.room.sources.Source import app.revanced.manager.domain.manager.SourceManager +import io.ktor.http.Url @Entity(tableName = "downloaders") data class DownloaderEntity( @PrimaryKey override val uid: Int, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Source, + @ColumnInfo(name = "url") val url: Url, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null ) : 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 deleted file mode 100644 index 060ed92674..0000000000 --- a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.revanced.manager.data.room.sources - -import androidx.room.ColumnInfo -import io.ktor.http.Url - - -sealed class Source { - object Local : Source() { - const val SENTINEL = "local" - - override fun toString() = SENTINEL - } - - object API : Source() { - const val SENTINEL = "api" - - override fun toString() = SENTINEL - } - - data class Remote(val url: Url) : Source() { - override fun toString() = url.toString() - } - - companion object { - fun from(value: String) = when (value) { - Local.SENTINEL -> Local - API.SENTINEL -> API - else -> Remote(Url(value)) - } - } -} - -data class SourceProperties( - @ColumnInfo(name = "name") val name: String, - @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, -) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt b/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt new file mode 100644 index 0000000000..33095c8696 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt @@ -0,0 +1,19 @@ +package app.revanced.manager.data.room.sources + +import androidx.room.ColumnInfo +import io.ktor.http.Url + +// The URL of a source as it is stored, used to migrate rows written before sources were URLs. +data class SourceUrl( + @ColumnInfo(name = "uid") val uid: Int, + @ColumnInfo(name = "url") val url: String, +) + +// The columns of a source row that can be updated after it was created. +data class SourceProperties( + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "version") val versionHash: String? = null, + @ColumnInfo(name = "url") val url: Url, + @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, + @ColumnInfo(name = "released_at") val releasedAt: Long? = null, +) diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index cfda5030db..866df6db96 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -1,9 +1,21 @@ package app.revanced.manager.di +import app.revanced.manager.domain.protocol.ContentProtocolHandler +import app.revanced.manager.domain.protocol.FileProtocolHandler +import app.revanced.manager.domain.protocol.HttpProtocolHandler import app.revanced.manager.network.service.HttpService +import app.revanced.manager.util.FilePicker +import app.revanced.manager.util.UiFilePicker +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val serviceModule = module { singleOf(::HttpService) -} \ No newline at end of file + singleOf(::UiFilePicker) { bind() } + single { androidContext().contentResolver } + singleOf(::HttpProtocolHandler) + singleOf(::ContentProtocolHandler) + singleOf(::FileProtocolHandler) +} 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 c956aa8d64..17c9960700 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 @@ -1,6 +1,8 @@ package app.revanced.manager.domain.manager import android.app.Application +import android.net.Uri +import io.ktor.http.Url import android.util.Log import androidx.annotation.StringRes import app.revanced.manager.R @@ -9,23 +11,21 @@ import app.revanced.manager.data.redux.Action import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.data.redux.Store import app.revanced.manager.data.room.AppDatabase.Companion.generateUid -import app.revanced.manager.data.room.sources.Source as SourceInfo import app.revanced.manager.data.room.sources.SourceProperties -import app.revanced.manager.domain.sources.APISource -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull -import app.revanced.manager.domain.sources.LocalSource -import app.revanced.manager.domain.sources.RemoteSource +import app.revanced.manager.data.room.sources.SourceUrl +import app.revanced.manager.domain.protocol.ContentProtocolHandler +import app.revanced.manager.domain.protocol.FileProtocolHandler +import app.revanced.manager.domain.protocol.HttpProtocolHandler +import app.revanced.manager.domain.protocol.ProtocolHandler +import app.revanced.manager.domain.protocol.getStream import app.revanced.manager.domain.sources.Source -import app.revanced.manager.domain.sources.UnsupportedRemoteSourceException -import app.revanced.manager.domain.sources.asRemoteSourceException +import app.revanced.manager.domain.sources.UnsupportedSourceException +import app.revanced.manager.domain.sources.asSourceException import app.revanced.manager.network.dto.ReVancedAsset -import app.revanced.manager.network.service.HttpService -import app.revanced.manager.network.utils.getOrThrow +import app.revanced.manager.network.dto.ReVancedAssetHistory import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.tag import app.revanced.manager.util.toast -import io.ktor.client.request.url -import io.ktor.http.Url import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,10 +37,11 @@ import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant +import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koin.core.component.inject import java.io.File -import java.io.InputStream import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.set @@ -55,10 +56,19 @@ abstract class SourceManager( protected val app: Application by inject() protected val prefs: PreferencesManager by inject() protected val networkInfo: NetworkInfo by inject() - protected val http: HttpService by inject() + protected val json: Json by inject() + + protected val protocolHandlers: Map = mapOf( + "http" to get(), + "https" to get(), + "content" to get(), + "file" to get(), + ) protected abstract suspend fun dbGetAll(): List protected abstract suspend fun dbGetProps(uid: Int): SourceProperties? + protected abstract suspend fun dbGetUrls(): List + protected abstract suspend fun dbSetUrl(uid: Int, url: String) protected abstract suspend fun dbUpsert(entity: DB) protected abstract suspend fun dbRemove(uid: Int) protected abstract suspend fun dbReset() @@ -66,6 +76,34 @@ abstract class SourceManager( protected abstract fun loadEntity(entity: DB): Source protected abstract fun entityFromProps(uid: Int, props: SourceProperties): DB + // The file the downloaded copy of a source lives in. + protected abstract fun fileOf(uid: Int): File + + // The URL of the default source, built from preferences. + protected abstract suspend fun defaultUrl(): Url + + // Resources of a source are nested under it, with the prerelease variant staying last: + // /v5/patches/prerelease has its version at /v5/patches/version/prerelease. + private fun resourceUriOf(uri: Uri, name: String): Uri { + val segments = uri.pathSegments + val isPrerelease = segments.lastOrNull() == PRERELEASE_PATH + + return uri.buildUpon().path(null).apply { + segments.dropLast(if (isPrerelease) 1 else 0).forEach(::appendPath) + appendPath(name) + if (isPrerelease) appendPath(PRERELEASE_PATH) + }.build() + } + + protected fun versionUriOf(uri: Uri) = resourceUriOf(uri, VERSION_PATH) + + // The releases a source has published. + suspend fun getHistory(uri: Uri): List = withContext(Dispatchers.IO) { + protocolHandlers.getStream(resourceUriOf(uri, HISTORY_PATH)) { stream -> + json.decodeFromString(stream.reader().readText()) + } + } + protected abstract fun realNameOf(loaded: LOADED): String? @get:StringRes @@ -80,16 +118,11 @@ abstract class SourceManager( @get:StringRes protected abstract val replaceFail: Int - protected abstract suspend fun loadDataFromSources(sources: MutableMap>): OUTPUT + // Shown when a URL does not lead to what this manager expects, e.g. patches. + @get:StringRes + protected abstract val urlUnsupported: Int - protected val defaultSource = entityFromProps( - 0, SourceProperties( - name = "", - versionHash = null, - source = SourceInfo.API, - autoUpdate = false - ) - ) + protected abstract suspend fun loadDataFromSources(sources: MutableMap>): OUTPUT protected val store = Store( CoroutineScope(Dispatchers.Default), @@ -129,7 +162,7 @@ abstract class SourceManager( updateDb(uid) { it.copy( name = newName, - releasedAt = (src as? RemoteSource)?.releasedAt?.toEpochMillis() + releasedAt = src.releasedAt?.toEpochMillis() ) } sources[uid] = src.copy(name = newName) @@ -144,28 +177,33 @@ abstract class SourceManager( } private suspend fun loadFromDb(): List { - val all = dbGetAll().toMutableList() - val default = defaultSource + migrateLegacyUrls() - if (all.none { it.uid == default.uid }) { - dbUpsert(default) - all += default + if (dbGetAll().none { it.uid == 0 }) { + createEntity(0, "", defaultUrl(), autoUpdate = true) } - return all + // Keeps the default source pointed at the API configured in settings. + dbGetProps(0)?.let { props -> + val url = defaultUrl() + if (url != props.url) updateDb(0) { it.copy(url = url) } + } + + return dbGetAll() } private suspend fun createEntity( + uid: Int, name: String, - source: SourceInfo, + url: Url, autoUpdate: Boolean = false, ) = entityFromProps( - uid = generateUid(), + uid = uid, SourceProperties( name = name, versionHash = null, - source = source, + url = url, autoUpdate = autoUpdate, releasedAt = null, ) @@ -188,7 +226,7 @@ abstract class SourceManager( SourceProperties( name = new.name, versionHash = new.versionHash, - source = new.source, + url = new.url, autoUpdate = new.autoUpdate, releasedAt = new.releasedAt, ) @@ -198,6 +236,22 @@ abstract class SourceManager( protected fun directoryOf(uid: Int) = sourceDir.resolve(uid.toString()).also { it.mkdirs() } + // The URL of the copy an imported source was stored in. + private fun fileUrlOf(uid: Int) = Url(Uri.fromFile(fileOf(uid)).toString()) + + // Rows written before sources were URLs hold a sentinel instead of one. + // They have to be rewritten before anything reads them because + // parsing a sentinel silently yields a valid but meaningless URL rather than failing. + private suspend fun migrateLegacyUrls() = dbGetUrls().forEach { (uid, url) -> + val migrated = when (url) { + LEGACY_LOCAL -> fileUrlOf(uid) + LEGACY_API -> defaultUrl() + else -> return@forEach + } + + dbSetUrl(uid, migrated.toString()) + } + suspend fun reset() = dispatchAction("Reset") { state -> dbReset() state.sources.keys.forEach { directoryOf(it).deleteRecursively() } @@ -225,72 +279,73 @@ abstract class SourceManager( ) } - suspend fun createLocal(createStream: suspend () -> InputStream) = - dispatchAction("Add local") { state -> - val entity = createEntity("", SourceInfo.Local) - with(loadEntity(entity) as LocalSource) { + suspend fun importFrom(uri: Uri) = + dispatchAction("Import ($uri)") { state -> + val uid = generateUid() + val entity = createEntity(uid, "", fileUrlOf(uid)) + with(loadEntity(entity)) { try { - createStream().use { patches -> replace(patches) } + replace(uri) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e(tag, "Got exception while creating local source", e) + Log.e(tag, "Got exception while importing source", e) withContext(Dispatchers.Main) { app.toast(app.getString(replaceFail, e.simpleMessage())) } - deleteLocalFile() + deleteFile() } } doReload(state) } - suspend fun createRemote(url: String, autoUpdate: Boolean) = - dispatchAction("Add remote ($url)") { state -> - val entity = createEntity("", SourceInfo.from(url), autoUpdate) - val src = loadEntity(entity) as RemoteSource + suspend fun create(url: String, autoUpdate: Boolean) = + dispatchAction("Add ($url)") { state -> + val entity = createEntity(generateUid(), "", Url(url), autoUpdate) + val src = loadEntity(entity) update(src) state.copy(sources = state.sources.toMutableMap().also { it[src.uid] = src }) } - suspend fun reloadApiSources() = dispatchAction("Reload API sources") { state -> - this@SourceManager.store.state.value.sources.values.filterIsInstance>() + suspend fun resetDefaultSource() = dispatchAction("Reset default source") { state -> + this@SourceManager.store.state.value.sources.values + .filter { it.isDefault } .forEach { src -> - with(src) { deleteLocalFile() } + with(src) { deleteFile() } updateDb(src.uid) { it.copy(versionHash = null, releasedAt = null) } } doReload(state) } - suspend fun RemoteSource.setAutoUpdate(value: Boolean) = + suspend fun Source.setAutoUpdate(value: Boolean) = dispatchAction("Set auto update ($name, $value)") { state -> updateDb(uid) { it.copy(autoUpdate = value) } - val newSrc = state.sources[uid]?.asRemoteOrNull?.copy(autoUpdate = value) + val newSrc = state.sources[uid]?.copy(autoUpdate = value) ?: return@dispatchAction state state.copy(sources = state.sources.toMutableMap().also { it[uid] = newSrc }) } - suspend fun RemoteSource.setEndpoint(value: String) = - dispatchAction("Set endpoint ($name, $value)") { state -> - val current = state.sources[uid]?.asRemoteOrNull ?: return@dispatchAction state - if (current.endpoint == value) return@dispatchAction state + suspend fun Source.setUrl(value: String) = + dispatchAction("Set URL ($name, $value)") { state -> + val current = state.sources[uid] ?: return@dispatchAction state + if (current.uri.toString() == value) return@dispatchAction state updateDb(uid) { props -> - if (props.source !is SourceInfo.Remote) return@updateDb props props.copy( - source = SourceInfo.Remote(Url(value)), + url = Url(value), versionHash = null, releasedAt = null ) } - with(current) { deleteLocalFile() } + with(current) { deleteFile() } val newSources = state.sources.toMutableMap() newSources[uid] = current.copy( error = null, - endpoint = value, + uri = Uri.parse(value), versionHash = null, releasedAt = null ) @@ -304,7 +359,7 @@ abstract class SourceManager( } suspend fun update( - vararg sources: RemoteSource, + vararg sources: Source, showToast: Boolean = false, force: Boolean = true ) { @@ -312,7 +367,7 @@ abstract class SourceManager( store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids }) } - suspend fun redownloadRemote() = + suspend fun redownload() = store.dispatch(Update(force = true, redownload = true)) /** @@ -325,25 +380,25 @@ abstract class SourceManager( ) { it.autoUpdate } ) - suspend fun validateRemoteUrl(url: String): String? = withContext(Dispatchers.IO) { + suspend fun validateUrl(url: String): String? = withContext(Dispatchers.IO) { runCatching { - http.request { - url(url) - }.getOrThrow() - }.exceptionOrNull()?.toRemoteValidationMessage() + protocolHandlers.getStream(Uri.parse(url)) { stream -> + json.decodeFromString(stream.reader().readText()) + } + }.exceptionOrNull()?.toValidationMessage() } - private fun Throwable.toRemoteValidationMessage() = when (asRemoteSourceException()) { + private fun Throwable.toValidationMessage() = when (asSourceException()) { // wtf is this? this data is not a bundle, at least something! - is UnsupportedRemoteSourceException -> app.getString(R.string.remote_source_url_unsupported) + is UnsupportedSourceException -> app.getString(urlUnsupported) // wtf is this? this is not a data at all and more like a webpage or something else! - else -> app.getString(R.string.remote_source_url_validation_failed) + else -> app.getString(R.string.source_url_validation_failed) } - private fun Throwable.toRemoteUpdateMessage() = when (asRemoteSourceException()) { + private fun Throwable.toUpdateMessage() = when (asSourceException()) { // wtf is this? this data is not a bundle, at least something! - is UnsupportedRemoteSourceException -> app.getString(R.string.remote_source_url_unsupported) + is UnsupportedSourceException -> app.getString(urlUnsupported) else -> simpleMessage() } @@ -351,12 +406,12 @@ abstract class SourceManager( private val force: Boolean = false, private val redownload: Boolean = false, private val showToast: Boolean = false, - private val predicate: (source: RemoteSource) -> Boolean = { true }, + private val predicate: (source: Source) -> Boolean = { true }, ) : Action> { private suspend fun toast(@StringRes id: Int, vararg args: Any?) = withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) } - override fun toString() = if (redownload) "Redownload remote sources" else "Update check" + override fun toString() = if (redownload) "Redownload sources" else "Update check" override suspend fun ActionContext.execute( current: State @@ -367,7 +422,6 @@ abstract class SourceManager( val outdated = current.outdatedSources.toMutableSet() val results = current.sources.values - .filterIsInstance>() .filter { predicate(it) } .also { targets -> // Clear errors for sources we are updating. @@ -383,7 +437,7 @@ abstract class SourceManager( val updateResult = it.runCatching { when { redownload -> downloadLatest() - checkOnly -> getUpdateInfo()?.let { info -> RemoteSource.UpdateResult(info.version, info.createdAt) } + checkOnly -> getUpdateInfo()?.let { info -> Source.UpdateResult(info.version, info.createdAt) } else -> update() } ?: return@update null } @@ -429,7 +483,7 @@ abstract class SourceManager( when { !showToast -> {} hasErrors -> { - val error = errors.values.first().toRemoteUpdateMessage() + val error = errors.values.first().toUpdateMessage() toast(updateFailed, error) } @@ -456,4 +510,10 @@ abstract class SourceManager( } } +private const val LEGACY_LOCAL = "local" +private const val LEGACY_API = "api" +private const val VERSION_PATH = "version" +private const val HISTORY_PATH = "history" +private const val PRERELEASE_PATH = "prerelease" + private fun LocalDateTime.toEpochMillis() = toInstant(TimeZone.UTC).toEpochMilliseconds() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt new file mode 100644 index 0000000000..0d9eacdf86 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt @@ -0,0 +1,56 @@ +package app.revanced.manager.domain.protocol + +import android.content.ContentResolver +import android.net.Uri +import app.revanced.manager.network.service.HttpService +import app.revanced.manager.util.FilePicker +import io.ktor.client.request.url +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.io.InputStream + +// Opens streams for the URI scheme it is registered for. +interface ProtocolHandler { + // Opens a stream to the resource behind [uri] and passes it to [block]. + // The stream is only valid inside [block] and is closed automatically afterwards. + suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T +} + +class HttpProtocolHandler(private val http: HttpService) : ProtocolHandler { + override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T) = + http.getStream(block) { url(uri.toString()) } +} + +// Opens content:// URIs, which the platform grants the app access to. +class ContentProtocolHandler(private val contentResolver: ContentResolver) : ProtocolHandler { + override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T { + val stream = withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri") + } + + return stream.use { block(it) } + } +} + +// Reading file:// URIs directly requires storage permissions, which the app avoids. +// The user instead picks the file through the system file picker, which yields +// a content:// URI the app is allowed to open. +class FileProtocolHandler( + private val filePicker: FilePicker, + private val contentProtocolHandler: ContentProtocolHandler +) : ProtocolHandler { + override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T { + val picked = filePicker.pickFile() ?: throw IOException("No file was selected") + return contentProtocolHandler.getStream(picked, block) + } +} + +// Opens a stream to [uri] with the handler registered for its scheme. +suspend fun Map.getStream( + uri: Uri, + block: suspend (InputStream) -> T +): T { + val handler = this[uri.scheme] ?: throw IOException("No handler for $uri") + return handler.getStream(uri, block) +} diff --git a/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt index 77d59e665b..3ea38221ad 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt @@ -14,13 +14,14 @@ import kotlinx.serialization.Serializable @Serializable sealed interface ChangelogSource : Parcelable { data object Manager : ChangelogSource - data class Patches(val url: String, val prerelease: Boolean) : ChangelogSource { - val baseUrl by lazy { url.toUri().let { "${it.scheme}://${it.host}" } } - } + + // The URL of the source itself, the changelog is a resource of it. + data class Patches(val url: String) : ChangelogSource } class ChangelogsRepository( private val api: ReVancedAPI, + private val patchBundleRepository: PatchBundleRepository, private val source: ChangelogSource, ) : PagingSource() { @@ -31,7 +32,7 @@ class ChangelogsRepository( api.getAppHistory().getOrThrow() is ChangelogSource.Patches -> - api.getPatchesHistory(source.baseUrl, source.prerelease).getOrThrow() + patchBundleRepository.getHistory(source.url.toUri()) } LoadResult.Page( 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..ec562a1959 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 @@ -15,11 +15,10 @@ import app.revanced.manager.R import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.downloader.DownloaderEntity import app.revanced.manager.data.room.sources.SourceProperties +import android.net.Uri +import io.ktor.http.Url import app.revanced.manager.domain.manager.SourceManager -import app.revanced.manager.domain.sources.APISource -import app.revanced.manager.domain.sources.JsonSource import app.revanced.manager.domain.sources.Loader -import app.revanced.manager.domain.sources.LocalSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.network.downloader.LoadedDownloader import app.revanced.manager.network.downloader.ParceledDownloaderData @@ -35,7 +34,6 @@ import java.io.File import java.lang.ref.WeakReference import java.lang.reflect.Modifier import kotlin.time.Instant -import app.revanced.manager.data.room.sources.Source as SourceInfo @OptIn(DownloaderHostApi::class) class DownloaderRepository( @@ -50,6 +48,8 @@ class DownloaderRepository( override suspend fun dbGetAll() = dao.all() override suspend fun dbGetProps(uid: Int) = dao.getProps(uid) + override suspend fun dbGetUrls() = dao.allUrls() + override suspend fun dbSetUrl(uid: Int, url: String) = dao.setUrl(uid, url) override suspend fun dbUpsert(entity: DownloaderEntity) = dao.upsert(entity) override suspend fun dbRemove(uid: Int) = dao.remove(uid) override suspend fun dbReset() = dao.reset() @@ -60,8 +60,9 @@ class DownloaderRepository( loadPackage(pkgInfo, dataDir) } + override fun fileOf(uid: Int): File = directoryOf(uid).resolve("downloader.jar") + override fun loadEntity(entity: DownloaderEntity): Source = with(entity) { - val file = directoryOf(uid).resolve("downloader.jar") val actualName = name.ifEmpty { app.getString(if (uid == 0) R.string.auto_updates_dialog_downloaders else R.string.source_name_fallback) } @@ -70,32 +71,20 @@ class DownloaderRepository( .toLocalDateTime(kotlinx.datetime.TimeZone.UTC) } - return when (source) { - is SourceInfo.Local -> LocalSource(actualName, uid, null, file, loader) - is SourceInfo.API -> APISource( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - SourceInfo.API.SENTINEL, - autoUpdate, - loader - ) { getDownloaderUpdate() } - - is SourceInfo.Remote -> JsonSource( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - source.url.toString(), - autoUpdate, - loader - ) - } + return Source( + actualName, + uid, + Uri.parse(url.toString()), + versionHash, + releasedAt, + autoUpdate, + null, + fileOf(uid), + loader, + protocolHandlers, + json, + ::versionUriOf + ) } override fun entityFromProps( @@ -105,16 +94,22 @@ class DownloaderRepository( uid, name = props.name, versionHash = props.versionHash, - source = props.source, + url = props.url, autoUpdate = props.autoUpdate, releasedAt = props.releasedAt ) + override suspend fun defaultUrl() = Url( + "${prefs.api.get()}/v5/manager/downloaders" + + if (prefs.useDownloaderPrerelease.get()) "/prerelease" else "" + ) + override fun realNameOf(loaded: DownloaderPackage) = loaded.name override val updateFailed = R.string.downloader_update_failed override val updateSuccess = R.string.patches_update_success override val updateUnavailable = R.string.patches_update_unavailable + override val urlUnsupported = R.string.downloader_url_unsupported override val replaceFail = R.string.downloader_replace_fail override suspend fun loadDataFromSources(sources: MutableMap>) = 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 3b6ad8a7bb..0545ca881e 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 @@ -8,14 +8,11 @@ import app.revanced.manager.R import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.sources.SourceProperties -import app.revanced.manager.data.room.sources.Source as SourceInfo -import app.revanced.manager.domain.sources.APIPatchBundle -import app.revanced.manager.domain.sources.JsonPatchBundle -import app.revanced.manager.domain.sources.LocalPatchBundle +import android.net.Uri +import io.ktor.http.Url import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.manager.SourceManager import app.revanced.manager.domain.sources.Loader -import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.sources.Source import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchBundle @@ -49,16 +46,20 @@ class PatchBundleRepository( override val updateFailed = R.string.patches_download_fail override val updateSuccess = R.string.patches_update_success override val updateUnavailable = R.string.patches_update_unavailable + override val urlUnsupported = R.string.patches_url_unsupported override val replaceFail = R.string.patches_replace_fail override suspend fun dbGetAll() = dao.all() override suspend fun dbGetProps(uid: Int) = dao.getProps(uid) + override suspend fun dbGetUrls() = dao.allUrls() + override suspend fun dbSetUrl(uid: Int, url: String) = dao.setUrl(uid, url) override suspend fun dbUpsert(entity: PatchBundleEntity) = dao.upsert(entity) override suspend fun dbRemove(uid: Int) = dao.remove(uid) override suspend fun dbReset() = dao.reset() + override fun fileOf(uid: Int): File = directoryOf(uid).resolve("patches.jar") + override fun loadEntity(entity: PatchBundleEntity): PatchBundleSource = with(entity) { - val file = directoryOf(uid).resolve("patches.jar") val actualName = entity.name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.source_name_fallback) } @@ -67,32 +68,20 @@ class PatchBundleRepository( .toLocalDateTime(TimeZone.UTC) } - return when (source) { - is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, file, PatchBundleLoader) - is SourceInfo.API -> APIPatchBundle( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - SourceInfo.API.SENTINEL, - autoUpdate, - PatchBundleLoader - ) { getPatchesUpdate() } - - is SourceInfo.Remote -> JsonPatchBundle( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - source.url.toString(), - autoUpdate, - PatchBundleLoader - ) - } + return Source( + actualName, + uid, + Uri.parse(url.toString()), + versionHash, + releasedAt, + autoUpdate, + null, + fileOf(uid), + PatchBundleLoader, + protocolHandlers, + json, + ::versionUriOf + ) } override fun entityFromProps( @@ -102,11 +91,16 @@ class PatchBundleRepository( uid, name = props.name, versionHash = props.versionHash, - source = props.source, + url = props.url, autoUpdate = props.autoUpdate, releasedAt = props.releasedAt ) + override suspend fun defaultUrl() = Url( + "${prefs.api.get()}/v5/patches" + + if (prefs.usePatchesPrereleases.get()) "/prerelease" else "" + ) + override fun realNameOf(loaded: PatchBundle) = loaded.manifestAttributes?.name override suspend fun loadDataFromSources(sources: MutableMap>) = loadMetadata(sources).toPersistentMap() @@ -185,7 +179,7 @@ class PatchBundleRepository( this[src.uid] = PatchBundleInfo.Global( src.name, bundle.manifestAttributes?.version, - (src as? RemotePatchBundle)?.releasedAt, + src.releasedAt, src.uid, result.getOrThrow().toList() ) diff --git a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt deleted file mode 100644 index e38a25f7d8..0000000000 --- a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt +++ /dev/null @@ -1,34 +0,0 @@ -package app.revanced.manager.domain.sources - -import app.revanced.manager.data.redux.ActionContext -import app.revanced.manager.patcher.patch.PatchBundle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.InputStream - -typealias LocalPatchBundle = LocalSource - -class LocalSource( - name: String, - uid: Int, - error: Throwable?, - file: File, - loader: Loader -) : Source(name, uid, error, file, loader) { - suspend fun ActionContext.replace(inputStream: InputStream) { - withContext(Dispatchers.IO) { - outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - } - - override fun copy(error: Throwable?, name: String) = LocalSource( - name, - uid, - error, - file, - loader - ) -} 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 deleted file mode 100644 index c6125bf2ac..0000000000 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ /dev/null @@ -1,166 +0,0 @@ -package app.revanced.manager.domain.sources - -import app.revanced.manager.data.redux.ActionContext -import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.dto.ReVancedAsset -import app.revanced.manager.network.service.HttpService -import app.revanced.manager.network.utils.APIFailure -import app.revanced.manager.network.utils.APIResponse -import app.revanced.manager.network.utils.getOrThrow -import app.revanced.manager.patcher.patch.PatchBundle -import io.ktor.client.request.url -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.io.File -import kotlinx.datetime.LocalDateTime -import kotlinx.serialization.SerializationException - -typealias RemotePatchBundle = RemoteSource -typealias JsonPatchBundle = JsonSource -typealias APIPatchBundle = APISource - -class UnsupportedRemoteSourceException(cause: Throwable? = null) : Exception(cause) - -internal fun Throwable.asRemoteSourceException(): Throwable { - if (this is UnsupportedRemoteSourceException) return this - - val hasSerializationFailure = generateSequence(this) { it.cause } - .any { it is SerializationException } - if (!hasSerializationFailure) return this - - return when (this) { - is APIFailure -> UnsupportedRemoteSourceException(this) - else -> UnsupportedRemoteSourceException(this) - } -} - -sealed class RemoteSource( - name: String, - uid: Int, - protected val versionHash: String?, - val releasedAt: LocalDateTime?, - error: Throwable?, - file: File, - val endpoint: String, - val autoUpdate: 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 abstract suspend fun getLatestInfo(): ReVancedAsset - abstract fun copy( - error: Throwable? = this.error, - name: String = this.name, - endpoint: String = this.endpoint, - autoUpdate: Boolean = this.autoUpdate, - versionHash: String? = this.versionHash, - releasedAt: LocalDateTime? = this.releasedAt - ): RemoteSource - - override fun copy(error: Throwable?, name: String): RemoteSource = - copy(error, name, this.endpoint, this.autoUpdate, this.versionHash, this.releasedAt) - - private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { - outputStream().use { - http.streamTo(it) { - url(info.downloadUrl) - } - } - - UpdateResult(info.version, info.createdAt) - } - - /** - * Downloads the latest version regardless if there is a new update available. - */ - suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) - suspend fun ActionContext.getUpdateInfo() = - getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } - - suspend fun ActionContext.update(): UpdateResult? = withContext(Dispatchers.IO) { - getUpdateInfo()?.let { download(it) } - } - - companion object { - const val updateFailMsg = "Failed to update" - } -} - -class JsonSource( - name: String, - uid: Int, - versionHash: String?, - releasedAt: LocalDateTime?, - error: Throwable?, - file: File, - endpoint: String, - autoUpdate: Boolean, - loader: Loader -) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { - override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { - runCatching { - http.request { - url(endpoint) - }.getOrThrow() - }.getOrElse { throw it.asRemoteSourceException() } - } - - override fun copy( - error: Throwable?, - name: String, - endpoint: String, - autoUpdate: Boolean, - versionHash: String?, - releasedAt: LocalDateTime? - ) = JsonSource( - name, - uid, - versionHash, - releasedAt, - error, - file, - endpoint, - autoUpdate, - loader - ) -} - -class APISource( - name: String, - uid: Int, - versionHash: String?, - releasedAt: LocalDateTime?, - error: Throwable?, - file: File, - endpoint: String, - autoUpdate: Boolean, - loader: Loader, - private val getUpdate: suspend ReVancedAPI.() -> APIResponse -) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { - private val api: ReVancedAPI by inject() - - override suspend fun getLatestInfo() = api.getUpdate().getOrThrow() - override fun copy( - error: Throwable?, - name: String, - endpoint: String, - autoUpdate: Boolean, - versionHash: String?, - releasedAt: LocalDateTime? - ) = APISource( - name, - uid, - versionHash, - releasedAt, - error, - file, - endpoint, - autoUpdate, - loader, - getUpdate - ) -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt index a25a22921a..69049da647 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt @@ -1,10 +1,20 @@ package app.revanced.manager.domain.sources +import android.net.Uri import androidx.compose.runtime.Stable import app.revanced.manager.data.redux.ActionContext +import app.revanced.manager.domain.protocol.ProtocolHandler +import app.revanced.manager.domain.protocol.getStream +import app.revanced.manager.network.dto.ReVancedAsset +import app.revanced.manager.network.dto.ReVancedAssetVersion +import app.revanced.manager.network.utils.APIFailure import app.revanced.manager.patcher.patch.PatchBundle +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json import java.io.File import java.io.OutputStream @@ -14,16 +24,37 @@ fun interface Loader { typealias PatchBundleSource = Source -/** - * A [PatchBundle] or [app.revanced.manager.downloader.Downloader] source. - */ +class UnsupportedSourceException(cause: Throwable? = null) : Exception(cause) + +internal fun Throwable.asSourceException(): Throwable { + if (this is UnsupportedSourceException) return this + + val hasSerializationFailure = generateSequence(this) { it.cause } + .any { it is SerializationException } + if (!hasSerializationFailure) return this + + return when (this) { + is APIFailure -> UnsupportedSourceException(this) + else -> UnsupportedSourceException(this) + } +} + +// A resource and the URL it is retrieved from. @Stable -sealed class Source( +class Source( val name: String, val uid: Int, + val uri: Uri, + val versionHash: String?, + val releasedAt: LocalDateTime?, + val autoUpdate: Boolean, error: Throwable?, - protected val file: File, - protected val loader: Loader + private val file: File, + private val loader: Loader, + private val handlers: Map, + private val json: Json, + // Where to read the version of this source, derived from its URL. + private val versionUriOf: (Uri) -> Uri ) { val state = when { error != null -> State.Failed(error) @@ -36,18 +67,41 @@ sealed class Source( } val isDefault inline get() = uid == 0 + val loaded get() = @Suppress("UNCHECKED_CAST") (state as? State.Available)?.obj val error get() = (state as? State.Failed)?.throwable - suspend fun ActionContext.deleteLocalFile() = withContext(Dispatchers.IO) { + data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) + + suspend fun ActionContext.deleteFile() = withContext(Dispatchers.IO) { file.delete() } - abstract fun copy(error: Throwable? = this.error, name: String = this.name): Source + fun copy( + error: Throwable? = this.error, + name: String = this.name, + uri: Uri = this.uri, + autoUpdate: Boolean = this.autoUpdate, + versionHash: String? = this.versionHash, + releasedAt: LocalDateTime? = this.releasedAt + ) = Source( + name, + uid, + uri, + versionHash, + releasedAt, + autoUpdate, + error, + file, + loader, + handlers, + json, + versionUriOf + ) - protected fun hasInstalled() = file.exists() + private fun hasInstalled() = file.exists() - protected fun outputStream(): OutputStream = with(file) { + private fun outputStream(): OutputStream = with(file) { // Android 14+ requires dex containers to be readonly. try { setWritable(true, true) @@ -57,6 +111,60 @@ sealed class Source( } } + private suspend fun getLatestInfo(): ReVancedAsset = withContext(Dispatchers.IO) { + runCatching { + handlers.getStream(uri) { stream -> + json.decodeFromString(stream.reader().readText()) + } + }.getOrElse { throw it.asSourceException() } + } + + private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { + handlers.getStream(Uri.parse(info.downloadUrl)) { stream -> + outputStream().use { stream.copyTo(it) } + } + + UpdateResult(info.version, info.createdAt) + } + + // The version resource of the source, returns null when the source does not serve one, which is not an error. + private suspend fun getLatestVersion(): String? = withContext(Dispatchers.IO) { + try { + handlers.getStream(versionUriOf(uri)) { stream -> + json.decodeFromString(stream.reader().readText()).version + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } + + /** + * Downloads the latest version regardless if there is a new update available. + */ + suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) + + suspend fun ActionContext.getUpdateInfo(): ReVancedAsset? { + // Reading the version alone is cheaper than the whole descriptor so use it to rule out an update first. Sources without a version resource fall through to the descriptor. + if (hasInstalled() && versionHash != null && getLatestVersion() == versionHash) return null + + return getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } + } + + suspend fun ActionContext.update(): UpdateResult? = withContext(Dispatchers.IO) { + getUpdateInfo()?.let { download(it) } + } + + // Replaces the content with the resource behind [uri], e.g. an imported file. + suspend fun ActionContext.replace(uri: Uri) { + withContext(Dispatchers.IO) { + handlers.getStream(uri) { stream -> + outputStream().use { stream.copyTo(it) } + } + } + } + sealed interface State { data object Missing : State data class Failed(val throwable: Throwable) : State @@ -65,6 +173,5 @@ sealed class Source( } object Extensions { - val Source.asRemoteOrNull inline get() = this as? RemoteSource val PatchBundleSource.version get() = loaded?.manifestAttributes?.version -} \ No newline at end of file +} diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index adcddcc3b8..650b1b5e01 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -46,19 +46,11 @@ class ReVancedAPI( suspend fun getAppHistory() = request>("manager/history${prefs.useManagerPrereleases.prereleaseString()}") - suspend fun getPatchesUpdate() = request("patches${prefs.usePatchesPrereleases.prereleaseString()}") - - suspend fun getPatchesHistory(apiUrl: String, prerelease: Boolean) = - request>(apiUrl, defaultApiVersion, "patches/history${prerelease.prereleaseString()}") - - suspend fun getDownloaderUpdate() = request("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}") - suspend fun getContributors() = request>("contributors") suspend fun getInfo() = request("about") private companion object { suspend fun Preference.prereleaseString() = if (get()) "/prerelease" else "" - fun Boolean.prereleaseString() = if (this) "/prerelease" else "" } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt index 27ea5b4bca..97fb7e1ed3 100644 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt @@ -16,6 +16,11 @@ data class ReVancedAsset ( val version: String, ) +@Serializable +data class ReVancedAssetVersion( + val version: String, +) + @Serializable data class ReVancedAssetHistory( val version: String, diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt index 6a4a87b25b..64e31559da 100644 --- a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt +++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt @@ -16,16 +16,16 @@ import io.ktor.http.isSuccess import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.exhausted import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.io.asSink import kotlinx.serialization.json.Json import java.io.File +import java.io.InputStream import java.io.OutputStream -/** - * @author Aliucord Authors, DiamondMiner88 - */ +// Additional authors: Aliucord Authors, DiamondMiner88 class HttpService( val json: Json, val http: HttpClient, @@ -85,6 +85,18 @@ class HttpService( } } + // Makes a GET request and passes the response body to [block] as a stream. + // The stream is only valid inside [block] and is closed automatically afterwards. + suspend fun getStream( + block: suspend (InputStream) -> T, + builder: HttpRequestBuilder.() -> Unit + ): T = http.prepareGet(builder).execute { response -> + if (!response.status.isSuccess()) throw HttpException(response.status) + + val channel: ByteReadChannel = response.body() + block(channel.toInputStream()) + } + suspend fun download( saveLocation: File, builder: HttpRequestBuilder.() -> Unit diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt index 632e5584ee..dc31ca3d7d 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt @@ -18,7 +18,7 @@ sealed class PatchBundleInfo { abstract val version: String? /** - * When this bundle was released. Only applicable for remote bundles. + * When this bundle was released, if known. */ abstract val releasedAt: LocalDateTime? diff --git a/app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt b/app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt new file mode 100644 index 0000000000..46477cabc1 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt @@ -0,0 +1,32 @@ +package app.revanced.manager.ui.component + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import app.revanced.manager.util.UiFilePicker +import app.revanced.manager.util.filePickerRequestChannel +import org.koin.compose.koinInject + +// Fulfills file pick requests sent by background code. Mounted once at the root +// of the UI, unconditionally, so pending activity results are always delivered. +@Composable +fun FilePickerRequestHost(picker: UiFilePicker = koinInject()) { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + picker.currentRequest?.result?.complete(uri) + picker.currentRequest = null + } + + LaunchedEffect(Unit) { + // A request may still be in flight from before a configuration change. + picker.currentRequest?.result?.await() + + for (request in filePickerRequestChannel) { + picker.currentRequest = request + launcher.launch("*/*") + + // One request at a time. + request.result.await() + } + } +} 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 84d6229cbc..0931aa0a58 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 @@ -1,23 +1,32 @@ package app.revanced.manager.ui.component.sources +import android.content.ContentResolver +import android.content.Context import android.net.Uri +import android.provider.OpenableColumns +import android.text.format.Formatter import android.webkit.URLUtil import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Topic +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable 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.text.input.KeyboardType @@ -28,152 +37,227 @@ 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.util.APK_MIMETYPE import app.revanced.manager.util.BIN_MIMETYPE import app.revanced.manager.util.transparentListItemColors import kotlinx.coroutines.launch -private enum class SourceType { - Local, - Remote +private enum class ImportMethod(val label: Int) { + // Free text: any protocol, the scheme decides how the source is resolved. + Auto(R.string.import_source_method_auto), + + // A web address, the most common kind of source. + Http(R.string.import_source_method_http), + + // A local file, picked through the system file picker. + File(R.string.import_source_method_file) } enum class ImportSourceDialogStrings( val title: Int, - val type_remote_description: Int, - val type_local_description: Int, - val import_local: Int, - val import_remote: Int + val urlLabel: Int ) { - PATCHES( - R.string.add_patches, - R.string.remote_patches_description, - R.string.local_patches_description, - R.string.patches, - R.string.patches_url - ), - DOWNLOADERS( - R.string.downloader_add, - R.string.remote_downloaders_description, - R.string.local_downloaders_description, - R.string.downloaders, - R.string.downloader_url - ), + PATCHES(R.string.add_patches, R.string.patches_url), + DOWNLOADERS(R.string.downloader_add, R.string.downloader_url), } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImportSourceDialog( strings: ImportSourceDialogStrings, onDismiss: () -> Unit, - validateRemote: suspend (String) -> String?, - onRemoteSubmit: (String, Boolean) -> Unit, - onLocalSubmit: (Uri) -> Unit + validateUrl: suspend (String) -> String?, + onUrlSubmit: (String, Boolean) -> Unit, + onFileSubmit: (Uri) -> Unit ) { - var currentStep by rememberSaveable { mutableIntStateOf(0) } - var sourceType by rememberSaveable { mutableStateOf(SourceType.Remote) } - var local by rememberSaveable { mutableStateOf(null) } - var remoteUrl by rememberSaveable { mutableStateOf("") } + var method by rememberSaveable { mutableStateOf(ImportMethod.Auto) } + var url by rememberSaveable { mutableStateOf("") } + var file by rememberSaveable { mutableStateOf(null) } var autoUpdate by rememberSaveable { mutableStateOf(true) } - var remoteValidationError by rememberSaveable { mutableStateOf(null) } - var isSubmittingRemote by rememberSaveable { mutableStateOf(false) } + var urlValidationError by rememberSaveable { mutableStateOf(null) } + var isSubmitting by rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val fileActivityLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri?.let { local = it } + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { picked -> + picked?.let { + file = it + // Selecting a file is an action of the input field, so show what was picked. + url = it.toString() + } } - fun launchFileActivity() { - 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, - remoteValidationError, - ::launchFileActivity, - { - remoteUrl = it - remoteValidationError = null - }, - { autoUpdate = it } - ) + fun launchFileActivity() = fileActivityLauncher.launch( + when (strings) { + ImportSourceDialogStrings.PATCHES -> BIN_MIMETYPE + ImportSourceDialogStrings.DOWNLOADERS -> APK_MIMETYPE } ) - val inputsAreValid by remember { - derivedStateOf { - (sourceType == SourceType.Local && local != null) || - (sourceType == SourceType.Remote && remoteUrl.isNotEmpty() && - (URLUtil.isHttpUrl(remoteUrl) || URLUtil.isHttpsUrl(remoteUrl))) - } + // Auto accepts any text and defers the scheme to the handlers, URL expects a + // well-formed web address before the network is even touched. + val isValidUrl = url.trim().let { URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) } + val inputsAreValid = when (method) { + ImportMethod.Auto -> url.isNotBlank() + ImportMethod.Http -> isValidUrl + ImportMethod.File -> file != null } AlertDialogExtended( onDismissRequest = onDismiss, - title = { - Text(stringResource(strings.title)) - }, + title = { Text(stringResource(strings.title)) }, text = { - steps[currentStep]() - }, - confirmButton = { - if (currentStep == steps.lastIndex) { - TextButton( - enabled = inputsAreValid && !isSubmittingRemote, - onClick = { - when (sourceType) { - SourceType.Local -> local?.let(onLocalSubmit) - SourceType.Remote -> { - val trimmedUrl = remoteUrl.trim() - coroutineScope.launch { - isSubmittingRemote = true - val validationError = validateRemote(trimmedUrl) - isSubmittingRemote = false + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Column(modifier = Modifier.padding(TextHorizontalPadding)) { + var expanded by remember { mutableStateOf(false) } - if (validationError == null) { - onRemoteSubmit(trimmedUrl, autoUpdate) - } else { - remoteValidationError = validationError - } - } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = stringResource(method.label), + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.import_source_method)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + ImportMethod.entries.forEach { entry -> + DropdownMenuItem( + text = { Text(stringResource(entry.label)) }, + onClick = { + expanded = false + method = entry + urlValidationError = null + }, + shape = MaterialTheme.shapes.medium + ) } } - }, - shapes = ButtonDefaults.shapes() - ) { - Text(stringResource(R.string.add)) + } } - } else { - TextButton(onClick = { currentStep++ }, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.next)) + + Spacer(modifier = Modifier.height(16.dp)) + + when (method) { + ImportMethod.Auto, ImportMethod.Http -> { + val showValidator = method == ImportMethod.Http && url.isNotEmpty() && !isValidUrl + Column(modifier = Modifier.padding(TextHorizontalPadding)) { + OutlinedTextField( + value = url, + onValueChange = { + url = it + urlValidationError = null + }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false + ), + label = { + Text( + stringResource( + if (method == ImportMethod.Auto) R.string.import_source_input + else strings.urlLabel + ) + ) + }, + placeholder = if (method == ImportMethod.Http) { + { Text("https://") } + } else null, + // Auto accepts a file too so offer the picker without making the user switch methods first. + trailingIcon = if (method == ImportMethod.Auto) { + { + TooltipIconButton( + onClick = ::launchFileActivity, + tooltip = stringResource(R.string.select_from_storage) + ) { + Icon( + imageVector = Icons.Outlined.Folder, + contentDescription = null + ) + } + } + } else null, + isError = showValidator || urlValidationError != null, + supportingText = { + when { + urlValidationError != null -> Text(urlValidationError!!) + showValidator -> Text(stringResource(R.string.input_dialog_value_invalid)) + } + } + ) + } + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp)) { + ListItem( + modifier = Modifier.clickable( + role = Role.Checkbox, + onClick = { autoUpdate = !autoUpdate } + ), + headlineContent = { Text(stringResource(R.string.auto_update)) }, + leadingContent = { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + HapticCheckbox( + checked = autoUpdate, + onCheckedChange = { autoUpdate = !autoUpdate } + ) + } + }, + colors = transparentListItemColors + ) + } + } + + ImportMethod.File -> FilePickerCard( + file = file, + onClick = ::launchFileActivity + ) } } }, + confirmButton = { + TextButton( + enabled = inputsAreValid && !isSubmitting, + onClick = { + when (method) { + ImportMethod.File -> file?.let(onFileSubmit) + ImportMethod.Auto, ImportMethod.Http -> { + val trimmedUrl = url.trim() + val picked = Uri.parse(trimmedUrl) + .takeIf { it.scheme == ContentResolver.SCHEME_CONTENT } + + if (picked != null) return@TextButton onFileSubmit(picked) + + coroutineScope.launch { + isSubmitting = true + val validationError = validateUrl(trimmedUrl) + isSubmitting = false + + if (validationError == null) onUrlSubmit(trimmedUrl, autoUpdate) + else urlValidationError = validationError + } + } + } + }, + shapes = ButtonDefaults.shapes() + ) { + Text(stringResource(R.string.add)) + } + }, dismissButton = { - if (currentStep > 0) { - TextButton(onClick = { currentStep-- }, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.back)) - } - } else { - TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.cancel)) - } + TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) { + Text(stringResource(R.string.cancel)) } }, textHorizontalPadding = PaddingValues(0.dp) @@ -181,142 +265,64 @@ fun ImportSourceDialog( } @Composable -private fun SelectSourceTypeStep( - strings: ImportSourceDialogStrings, - sourceType: SourceType, - onSourceTypeSelected: (SourceType) -> Unit +private fun FilePickerCard( + file: Uri?, + onClick: () -> Unit ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Column { - ListItem( - modifier = Modifier.clickable( - role = Role.RadioButton, - onClick = { onSourceTypeSelected(SourceType.Remote) } - ), - headlineContent = { Text(stringResource(R.string.enter_url)) }, - overlineContent = { Text(stringResource(R.string.recommended)) }, - supportingContent = { Text(stringResource(strings.type_remote_description)) }, - leadingContent = { - HapticRadioButton( - selected = sourceType == SourceType.Remote, - onClick = null - ) - }, - colors = transparentListItemColors - ) - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) - ListItem( - modifier = Modifier.clickable( - role = Role.RadioButton, - onClick = { onSourceTypeSelected(SourceType.Local) } - ), - headlineContent = { Text(stringResource(R.string.select_from_storage)) }, - supportingContent = { Text(stringResource(strings.type_local_description)) }, - overlineContent = { }, - leadingContent = { - HapticRadioButton( - selected = sourceType == SourceType.Local, - onClick = null - ) - }, - colors = transparentListItemColors - ) - } - } -} + val context = LocalContext.current + val info = remember(file) { file?.let { context.readFileInfo(it) } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun ImportSourceStep( - strings: ImportSourceDialogStrings, - sourceType: SourceType, - local: Uri?, - remoteUrl: String, - autoUpdate: Boolean, - remoteValidationError: String?, - launchFileActivity: () -> Unit, - onRemoteUrlChange: (String) -> Unit, - onAutoUpdateChange: (Boolean) -> Unit -) { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - when (sourceType) { - SourceType.Local -> { - Column( - modifier = Modifier.padding(horizontal = 8.dp) - ) { - ListItem( - headlineContent = { - Text(stringResource(strings.import_local)) - }, - supportingContent = { Text(stringResource(if (local != null) R.string.file_field_set else R.string.file_field_not_set)) }, - trailingContent = { - TooltipIconButton( - onClick = launchFileActivity, - tooltip = stringResource(strings.import_local) - ) { - Icon(imageVector = Icons.Default.Topic, contentDescription = null) - } - }, - modifier = Modifier.clickable { launchFileActivity() }, - colors = transparentListItemColors - ) - } - } - - SourceType.Remote -> { - val isUrl = remoteUrl.trim().let { - URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) - } - // We check if the value is empty to avoid showing an error state when the user - // hasn't entered anything yet to avoid bashing user with bad UX of blaming the - // user immediately when everything is intended. - val validator = remoteUrl.isNotEmpty() && !isUrl - Column( - modifier = Modifier.padding(TextHorizontalPadding) - ) { - OutlinedTextField( - value = remoteUrl, - onValueChange = onRemoteUrlChange, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - autoCorrectEnabled = false - ), - label = { Text(stringResource(strings.import_remote)) }, - isError = validator || remoteValidationError != null, - supportingText = { - when { - remoteValidationError != null -> Text(remoteValidationError) - validator -> Text(stringResource(R.string.input_dialog_value_invalid)) - } - }, + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + headlineContent = { + Text(info?.name ?: stringResource(R.string.file_field_not_set)) + }, + supportingContent = { + Text( + info?.size?.let { Formatter.formatShortFileSize(context, it) } + ?: stringResource(R.string.import_source_press_to_select) + ) + }, + leadingContent = if (file != null) { + { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp) + } else null, + trailingContent = { + TooltipIconButton( + onClick = onClick, + tooltip = stringResource(R.string.select_from_storage) ) { - ListItem( - modifier = Modifier.clickable( - role = Role.Checkbox, - onClick = { onAutoUpdateChange(!autoUpdate) } - ), - headlineContent = { Text(stringResource(R.string.auto_update)) }, - leadingContent = { - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { - HapticCheckbox( - checked = autoUpdate, - onCheckedChange = { - onAutoUpdateChange(!autoUpdate) - } - ) - } - }, - colors = transparentListItemColors - ) + Icon(imageVector = Icons.Outlined.Edit, contentDescription = null) } - } - } + }, + colors = transparentListItemColors + ) + } +} + +private class FileInfo(val name: String?, val size: Long?) + +private fun Context.readFileInfo(uri: Uri): FileInfo = runCatching { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + + fun column(name: String) = cursor.getColumnIndex(name).takeIf { it >= 0 && !cursor.isNull(it) } + + FileInfo( + name = column(OpenableColumns.DISPLAY_NAME)?.let(cursor::getString), + size = column(OpenableColumns.SIZE)?.let(cursor::getLong) + ) } -} \ No newline at end of file +}.getOrNull() ?: FileInfo(uri.lastPathSegment, null) 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 a024c67410..c107b8f1b1 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 @@ -58,8 +58,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.repository.ChangelogSource -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull -import app.revanced.manager.domain.sources.LocalSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ConfirmDialog @@ -86,10 +84,9 @@ fun BundleInformationScreen( val patchCount by viewModel.patchCount.collectAsStateWithLifecycle(0) var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } - val isLocal = src is LocalSource<*> val bundleManifestAttributes = src.loaded?.manifestAttributes - val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint } - ?: (null to null) + val autoUpdate = src.autoUpdate + val sourceUrl = src.uri.toString() val subtitleAuthor = bundleManifestAttributes?.author?.let { stringResource(R.string.bundle_information_by_author, it) @@ -131,7 +128,7 @@ fun BundleInformationScreen( val separator = "$emSpace$dot$emSpace" Text(text = buildAnnotatedString { append("$subtitleAuthor$separator$subtitleVersion") - src.asRemoteOrNull?.releasedAt?.let { + src.releasedAt?.let { val releaseDate = it.relativeTime( LocalContext.current ) @@ -165,7 +162,7 @@ fun BundleInformationScreen( contentDescription ) } - if (!isLocal) TooltipIconButton( + TooltipIconButton( onClick = viewModel::refresh, tooltip = stringResource(R.string.refresh), ) { contentDescription -> @@ -241,36 +238,34 @@ fun BundleInformationScreen( } ListSection { - if (autoUpdate != null) { - SettingsListItem( - headlineContent = stringResource(R.string.auto_update), - supportingContent = stringResource(R.string.auto_update_description), - trailingContent = { - HapticSwitch( - checked = autoUpdate, - onCheckedChange = viewModel::setAutoUpdate, - thumbContent = if (autoUpdate) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } + SettingsListItem( + headlineContent = stringResource(R.string.auto_update), + supportingContent = stringResource(R.string.auto_update_description), + trailingContent = { + HapticSwitch( + checked = autoUpdate, + onCheckedChange = viewModel::setAutoUpdate, + thumbContent = if (autoUpdate) { + { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) } - ) - }, - onClick = { viewModel.setAutoUpdate(!autoUpdate) } - ) - } + } else { + { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + } + ) + }, + onClick = { viewModel.setAutoUpdate(!autoUpdate) } + ) if (src.isDefault) { SafeguardBooleanItem( @@ -286,7 +281,7 @@ fun BundleInformationScreen( ) } - endpoint?.takeUnless { src.isDefault }?.let { url -> + sourceUrl.takeUnless { src.isDefault }?.let { url -> var showUrlInputDialog by rememberSaveable { mutableStateOf(false) } if (showUrlInputDialog) { @@ -294,10 +289,10 @@ fun BundleInformationScreen( initial = url, title = stringResource(R.string.patches_url), onDismissRequest = { showUrlInputDialog = false }, - confirmValidator = viewModel::validateEndpoint, + confirmValidator = viewModel::validateUrl, onConfirm = { showUrlInputDialog = false - viewModel.setEndpoint(it.trim()) + viewModel.setUrl(it.trim()) }, validator = { val value = it.trim() @@ -327,25 +322,10 @@ fun BundleInformationScreen( trailingContent = null ) - endpoint?.let { - SettingsListItem( - headlineContent = stringResource(R.string.changelog), - onClick = { - val source = if (src.isDefault) { - ChangelogSource.Patches( - url = viewModel.prefs.api.getBlocking(), - prerelease = viewModel.prefs.usePatchesPrereleases.getBlocking() - ) - } else { - ChangelogSource.Patches( - url = endpoint, - prerelease = false - ) - } - onChangelogClick(source) - }, - ) - } + SettingsListItem( + headlineContent = stringResource(R.string.changelog), + onClick = { onChangelogClick(ChangelogSource.Patches(sourceUrl)) }, + ) src.error?.let { var showDialog by rememberSaveable { mutableStateOf(false) } @@ -370,7 +350,7 @@ fun BundleInformationScreen( ) } - if (src.state is Source.State.Missing && !isLocal) { + if (src.state is Source.State.Missing) { SettingsListItem( headlineContent = stringResource(R.string.patches_error), supportingContent = stringResource(R.string.patches_not_downloaded), diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index c9c8a44497..7a94d0316f 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -200,16 +200,16 @@ fun DashboardScreen( ImportSourceDialog( strings = ImportSourceDialogStrings.PATCHES, onDismiss = { showAddBundleDialog = false }, - validateRemote = vm::validateRemoteSourceUrl, - onLocalSubmit = { patches -> + validateUrl = vm::validateSourceUrl, + onFileSubmit = { patches -> showAddBundleDialog = false patchesSourceEditMode = false - vm.createLocalSource(patches) + vm.importSource(patches) }, - onRemoteSubmit = { url, autoUpdate -> + onUrlSubmit = { url, autoUpdate -> showAddBundleDialog = false patchesSourceEditMode = false - vm.createRemoteSource(url, autoUpdate) + vm.createSource(url, autoUpdate) } ) } 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 593358d326..c408e6dd9c 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 @@ -40,7 +40,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.EmptyState @@ -69,8 +68,6 @@ fun DownloaderInfoScreen( ) { val downloaderStates by viewModel.downloaderSources.collectAsStateWithLifecycle(emptyMap()) val source = downloaderStates[uid] ?: return - val remote = source.asRemoteOrNull - val appName = source.name val displayNames = remember(source) { @@ -112,8 +109,8 @@ fun DownloaderInfoScreen( Text( text = buildAnnotatedString { append("v$it") - if (remote?.releasedAt != null) { - val releaseDate = remote.releasedAt.relativeTime( + source.releasedAt?.let { releasedAt -> + val releaseDate = releasedAt.relativeTime( LocalContext.current ).lowercase(getDefault()) @@ -143,14 +140,12 @@ fun DownloaderInfoScreen( Icon(Icons.Filled.Delete, stringResource(R.string.delete)) } - remote?.let { - TooltipIconButton( - onClick = { viewModel.updateDownloader(it) }, - enabled = !isDeleting, - tooltip = stringResource(R.string.update) - ) { - Icon(Icons.Filled.Refresh, stringResource(R.string.update)) - } + TooltipIconButton( + onClick = { viewModel.updateDownloader(source) }, + enabled = !isDeleting, + tooltip = stringResource(R.string.update) + ) { + Icon(Icons.Filled.Refresh, stringResource(R.string.update)) } }, scrollBehavior = scrollBehavior @@ -169,37 +164,35 @@ fun DownloaderInfoScreen( ListSection( modifier = Modifier.padding(top = 16.dp) ) { - remote?.let { remoteSource -> - val autoUpdate = remoteSource.autoUpdate - SettingsListItem( - headlineContent = stringResource(R.string.auto_update), - supportingContent = stringResource(R.string.auto_update_description), - trailingContent = { - HapticSwitch( - checked = autoUpdate, - onCheckedChange = { viewModel.setAutoUpdate(remoteSource, it) }, - thumbContent = if (autoUpdate) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } + val autoUpdate = source.autoUpdate + SettingsListItem( + headlineContent = stringResource(R.string.auto_update), + supportingContent = stringResource(R.string.auto_update_description), + trailingContent = { + HapticSwitch( + checked = autoUpdate, + onCheckedChange = { viewModel.setAutoUpdate(source, it) }, + thumbContent = if (autoUpdate) { + { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) } - ) - }, - onClick = { viewModel.setAutoUpdate(remoteSource, !autoUpdate) } - ) - } + } else { + { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + } + ) + }, + onClick = { viewModel.setAutoUpdate(source, !autoUpdate) } + ) if (source.isDefault) { SafeguardBooleanItem( @@ -215,7 +208,7 @@ fun DownloaderInfoScreen( ) } - remote?.endpoint?.takeUnless { source.isDefault }?.let { url -> + source.uri.toString().takeUnless { source.isDefault }?.let { url -> var showUrlInputDialog by rememberSaveable { mutableStateOf(false) } if (showUrlInputDialog) { @@ -223,10 +216,10 @@ fun DownloaderInfoScreen( initial = url, title = stringResource(R.string.downloader_url), onDismissRequest = { showUrlInputDialog = false }, - confirmValidator = viewModel::validateRemoteSourceUrl, + confirmValidator = viewModel::validateSourceUrl, onConfirm = { showUrlInputDialog = false - viewModel.setEndpoint(remote, it.trim()) + viewModel.setUrl(source, it.trim()) }, validator = { val value = it.trim() @@ -281,7 +274,7 @@ fun DownloaderInfoScreen( EmptyState( icon = Icons.Outlined.SignalWifiOff, title = R.string.downloader_sources_unavailable_title, - description = if (remote != null) R.string.downloader_sources_unavailable_with_remote_description else R.string.downloader_sources_unavailable_without_remote_description + description = R.string.downloader_sources_unavailable_description ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index b31159db8e..08c44b6515 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.data.room.apps.downloaded.DownloadedApp -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.sources.Source.State import app.revanced.manager.network.downloader.DownloaderPackage @@ -128,14 +127,14 @@ fun DownloadsSettingsScreen( ImportSourceDialog( strings = ImportSourceDialogStrings.DOWNLOADERS, onDismiss = { showImportDialog = false }, - validateRemote = viewModel::validateRemoteSourceUrl, - onLocalSubmit = { uri -> + validateUrl = viewModel::validateSourceUrl, + onFileSubmit = { uri -> showImportDialog = false - viewModel.createLocalSource(uri) + viewModel.importSource(uri) }, - onRemoteSubmit = { url, autoUpdate -> + onUrlSubmit = { url, autoUpdate -> showImportDialog = false - viewModel.createRemoteSource(url, autoUpdate) + viewModel.createSource(url, autoUpdate) } ) } @@ -333,7 +332,7 @@ private fun DownloaderItem( val version = source.loaded?.version val relativeTime = - (source.asRemoteOrNull)?.releasedAt?.relativeTime(LocalContext.current) + source.releasedAt?.relativeTime(LocalContext.current) Text( text = buildAnnotatedString { 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 46b5c10b88..dbb1a20cd3 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 @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -23,33 +22,36 @@ class BundleInformationViewModel(uid: Int) : ViewModel(), KoinComponent { } fun refresh() = viewModelScope.launch { - bundle.first()?.asRemoteOrNull?.let { + bundle.first()?.let { patchBundleRepository.update(it, showToast = true) } } fun setAutoUpdate(value: Boolean) = viewModelScope.launch { - bundle.first()?.asRemoteOrNull?.let { + bundle.first()?.let { patchBundleRepository.run { it.setAutoUpdate(value) } } } - fun setEndpoint(value: String) = viewModelScope.launch { - val endpoint = value.trim() - bundle.first()?.asRemoteOrNull?.let { current -> - if (current.endpoint == endpoint) return@launch + fun setUrl(value: String) = viewModelScope.launch { + val url = value.trim() + bundle.first()?.let { current -> + if (current.uri.toString() == url) return@launch - patchBundleRepository.run { current.setEndpoint(endpoint) } - bundle.first()?.asRemoteOrNull?.let { updated -> + patchBundleRepository.run { current.setUrl(url) } + bundle.first()?.let { updated -> patchBundleRepository.update(updated, showToast = true) } } } - suspend fun validateEndpoint(value: String) = patchBundleRepository.validateRemoteUrl(value.trim()) + suspend fun validateUrl(value: String) = patchBundleRepository.validateUrl(value.trim()) fun updateUsePrereleases(value: Boolean) = viewModelScope.launch { prefs.usePatchesPrereleases.update(value) + + // Rebuilds the default source with the URL of the new release channel. + patchBundleRepository.reload() refresh() } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt index f1276b6b48..c972f1f425 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.domain.sources.PatchBundleSource -import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.util.mutableStateSetOf import kotlinx.coroutines.flow.combine @@ -52,7 +51,7 @@ class BundleListViewModel : ViewModel(), KoinComponent { Event.UPDATE_SELECTED -> viewModelScope.launch { patchBundleRepository.update( - *getSelectedSources().filterIsInstance().toTypedArray(), + *getSelectedSources().toTypedArray(), showToast = true ) } @@ -63,8 +62,6 @@ class BundleListViewModel : ViewModel(), KoinComponent { viewModelScope.launch { patchBundleRepository.remove(src) } fun update(src: PatchBundleSource) = viewModelScope.launch { - if (src !is RemotePatchBundle) return@launch - patchBundleRepository.update(src, showToast = true) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt index 064650464f..f64820e72f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt @@ -8,12 +8,14 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.repository.ChangelogsRepository +import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAssetHistory import kotlinx.coroutines.flow.Flow class ChangelogsViewModel( private val api: ReVancedAPI, + private val patchBundleRepository: PatchBundleRepository, private val source: ChangelogSource, ) : ViewModel() { val changelogs: Flow> = Pager( @@ -21,6 +23,6 @@ class ChangelogsViewModel( pageSize = 10, enablePlaceholders = false ), - pagingSourceFactory = { ChangelogsRepository(api, source) } + pagingSourceFactory = { ChangelogsRepository(api, patchBundleRepository, source) } ).flow.cachedIn(viewModelScope) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt index e599961442..a1dbe406d4 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt @@ -1,8 +1,6 @@ package app.revanced.manager.ui.viewmodel -import android.annotation.SuppressLint import android.app.Application -import android.content.ContentResolver import android.net.Uri import android.os.Build import androidx.compose.runtime.getValue @@ -38,7 +36,6 @@ class DashboardViewModel( val availablePatches = patchBundleRepository.bundleInfoFlow.map { it.values.sumOf { bundle -> bundle.patches.size } } val bundleDownloadError = patchBundleRepository.apiOutageError - private val contentResolver: ContentResolver = app.contentResolver val hasUpdate = managerUpdateRepository.hasUpdate val updateVersion = managerUpdateRepository.version @@ -124,14 +121,13 @@ class DashboardViewModel( patchBundleRepository.remove(source) } - @SuppressLint("Recycle") - fun createLocalSource(patchBundle: Uri) = viewModelScope.launch { - patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! } + fun importSource(patchBundle: Uri) = viewModelScope.launch { + patchBundleRepository.importFrom(patchBundle) } - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { - patchBundleRepository.createRemote(apiUrl, autoUpdate) + fun createSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { + patchBundleRepository.create(apiUrl, autoUpdate) } - suspend fun validateRemoteSourceUrl(apiUrl: String) = patchBundleRepository.validateRemoteUrl(apiUrl) + suspend fun validateSourceUrl(apiUrl: String) = patchBundleRepository.validateUrl(apiUrl) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt index f58c129580..b8a4536c70 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt @@ -4,7 +4,6 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R -import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.repository.PatchBundleRepository @@ -20,8 +19,8 @@ class DeveloperOptionsViewModel( private val downloaderRepository: DownloaderRepository ) : ViewModel() { fun redownloadBundles() = viewModelScope.launch { - uiSafe(app, R.string.patches_download_fail, RemotePatchBundle.updateFailMsg) { - patchBundleRepository.redownloadRemote() + uiSafe(app, R.string.patches_download_fail, "Failed to update") { + patchBundleRepository.redownload() } } @@ -31,7 +30,7 @@ class DeveloperOptionsViewModel( prefs.api.update(value) arrayOf(patchBundleRepository, downloaderRepository).forEach { - it.reloadApiSources() + it.resetDefaultSource() it.updateCheck() } } 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 cad37e9a15..6aacd39734 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 @@ -1,7 +1,5 @@ package app.revanced.manager.ui.viewmodel -import android.annotation.SuppressLint -import android.app.Application import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -13,8 +11,6 @@ import app.revanced.manager.data.room.apps.downloaded.DownloadedApp import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloaderRepository -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull -import app.revanced.manager.domain.sources.RemoteSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.network.downloader.DownloaderPackage import app.revanced.manager.util.PM @@ -27,14 +23,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class DownloadsViewModel( - app: Application, private val downloadedAppRepository: DownloadedAppRepository, private val downloaderRepository: DownloaderRepository, prefs: PreferencesManager, val pm: PM, val networkInfo: NetworkInfo, ) : ViewModel() { - private val contentResolver = app.contentResolver val usePrereleases = prefs.useDownloaderPrerelease val downloaderSources = downloaderRepository.downloaderSources val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps -> @@ -57,20 +51,22 @@ class DownloadsViewModel( fun updateUsePrereleases(value: Boolean) = viewModelScope.launch { usePrereleases.update(value) - val apiSource = downloaderRepository.downloaderSources.first()[0]?.asRemoteOrNull ?: return@launch + + // Rebuilds the default source with the URL of the new release channel. + downloaderRepository.reload() + val apiSource = downloaderRepository.downloaderSources.first()[0] ?: return@launch updateDownloader(apiSource) } - @SuppressLint("Recycle") - fun createLocalSource(downloaderUri: Uri) = viewModelScope.launch { - downloaderRepository.createLocal { contentResolver.openInputStream(downloaderUri)!! } + fun importSource(downloaderUri: Uri) = viewModelScope.launch { + downloaderRepository.importFrom(downloaderUri) } - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { - downloaderRepository.createRemote(apiUrl, autoUpdate) + fun createSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { + downloaderRepository.create(apiUrl, autoUpdate) } - suspend fun validateRemoteSourceUrl(apiUrl: String) = downloaderRepository.validateRemoteUrl(apiUrl) + suspend fun validateSourceUrl(apiUrl: String) = downloaderRepository.validateUrl(apiUrl) fun toggleApp(downloadedApp: DownloadedApp) { if (appSelection.contains(downloadedApp)) @@ -104,7 +100,7 @@ class DownloadsViewModel( } } - fun updateDownloader(src: RemoteSource) = viewModelScope.launch { + fun updateDownloader(src: Source) = viewModelScope.launch { try { isUpdatingDownloader = true downloaderRepository.update(src, showToast = true) @@ -113,21 +109,21 @@ class DownloadsViewModel( } } - fun setAutoUpdate(src: RemoteSource, value: Boolean) = viewModelScope.launch { + fun setAutoUpdate(src: Source, value: Boolean) = viewModelScope.launch { with(downloaderRepository) { src.setAutoUpdate(value) } } - fun setEndpoint(src: RemoteSource, value: String) = viewModelScope.launch { - val endpoint = value.trim() - if (src.endpoint == endpoint) return@launch + fun setUrl(src: Source, value: String) = viewModelScope.launch { + val url = value.trim() + if (src.uri.toString() == url) return@launch with(downloaderRepository) { - src.setEndpoint(endpoint) + src.setUrl(url) } - downloaderSources.first()[src.uid]?.asRemoteOrNull?.let { updated -> + downloaderSources.first()[src.uid]?.let { updated -> updateDownloader(updated) } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt index 648e44fd03..cb76bbe176 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.lifecycle.ViewModel -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.repository.PatchBundleRepository @@ -88,8 +87,7 @@ class OnboardingViewModel( with(patchBundleRepository) { val src = sources .first() - .find { it.isDefault } - ?.asRemoteOrNull ?: return@with + .find { it.isDefault } ?: return@with src.setAutoUpdate(patchesEnabled) update(src) @@ -97,8 +95,7 @@ class OnboardingViewModel( with(downloaderRepository) { val src = downloaderSources - .first()[0] - ?.asRemoteOrNull ?: return@with + .first()[0] ?: return@with src.setAutoUpdate(downloadersEnabled) update(src) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index 038218727b..1a6f86352f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -18,7 +18,6 @@ import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.sources.Source.State import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Extensions.version import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.PatchBundleInfo @@ -394,7 +393,7 @@ private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped( private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped( name = name, version = version, - releasedAt = (this.asRemoteOrNull)?.releasedAt, + releasedAt = releasedAt, uid = uid, patches = emptyList(), compatible = emptyList(), diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt index 5a5f1e15d8..7fada7f591 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -21,6 +21,7 @@ import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.repository.ChangelogsRepository +import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.ManagerUpdateRepository import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset @@ -45,6 +46,7 @@ import ru.solrudev.ackpine.session.parameters.Confirmation class UpdateViewModel( private val api: ReVancedAPI, + private val patchBundleRepository: PatchBundleRepository, private val source: ChangelogSource, private val downloadOnScreenEntry: Boolean, private val app: Application, @@ -83,7 +85,7 @@ class UpdateViewModel( pageSize = 10, enablePlaceholders = false ), - pagingSourceFactory = { ChangelogsRepository(api, source) } + pagingSourceFactory = { ChangelogsRepository(api, patchBundleRepository, source) } ).flow.cachedIn(viewModelScope) private val location = fs.uiTempDir.resolve("updater.apk") diff --git a/app/src/main/java/app/revanced/manager/util/FilePicker.kt b/app/src/main/java/app/revanced/manager/util/FilePicker.kt new file mode 100644 index 0000000000..eed93c26ec --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/FilePicker.kt @@ -0,0 +1,34 @@ +package app.revanced.manager.util + +import android.net.Uri +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel + +// A request to let the user pick a file, answered with a content:// URI +// or null if the picker was cancelled. The sender suspends on [result] +// until the UI completes it. +class FilePickerRequest { + val result = CompletableDeferred() +} + +val filePickerRequestChannel = Channel() + +// Lets background code ask the user to pick a file without dealing with +// the request machinery. +interface FilePicker { + suspend fun pickFile(): Uri? +} + +class UiFilePicker : FilePicker { + // The request currently shown to the user. Lives here rather than in the + // composition so a result delivered after activity recreation still + // completes it. + var currentRequest: FilePickerRequest? = null + + override suspend fun pickFile(): Uri? { + val request = FilePickerRequest() + filePickerRequestChannel.send(request) + + return request.result.await() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b251ce8869..3acc8ab6f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,12 @@ Second \"item\" text" Import Import patches + Method + Auto + HTTP + File + Input + Press to import Selected Not selected @@ -219,8 +225,7 @@ You won’t be able to update apps that were signed with the previous keystore.< Add downloader from local storage Downloader can receive updates These downloaders are missing - Tap Update to fix this issue - Make sure that you imported the correct downloader + Tap Update to fix this issue Failed to update downloader: %s Failed to import downloader: %s @@ -545,8 +550,9 @@ It’s only compatible with these versions: %2$s %sd ago Invalid date Invalid value - This URL is pointing to an unsupported source. - Couldn’t verify this URL. Make sure it’s correct and try again. + This URL does not point to valid patches. + This URL does not point to a valid downloader. + Couldn’t verify this URL. Make sure it’s correct and try again. Required options Couldn’t check for updates: %s