mirror of
https://github.com/ReVanced/revanced-manager
synced 2026-04-25 17:15:36 +02:00
Merge branch 'dev' into feat/redesign-selected-app-info
This commit is contained in:
@@ -1,3 +1,57 @@
|
||||
# app [2.6.0-dev.7](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.6...v2.6.0-dev.7) (2026-04-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Improve patch options ([#3213](https://github.com/ReVanced/revanced-manager/issues/3213)) ([90667c8](https://github.com/ReVanced/revanced-manager/commit/90667c8c141c743b11a189e7ab3061ee58c7bc87))
|
||||
|
||||
# app [2.6.0-dev.6](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.5...v2.6.0-dev.6) (2026-04-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Missing database migration file [no ci] ([df9b44c](https://github.com/ReVanced/revanced-manager/commit/df9b44c9f4959c81e16a1665e8066bd01e97b1fe))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Improve AppsScreen design, add app pinning and jump to top ([#3240](https://github.com/ReVanced/revanced-manager/issues/3240)) ([46720a4](https://github.com/ReVanced/revanced-manager/commit/46720a4a885ad5aa321ab855439bb30c292cb17f))
|
||||
|
||||
# app [2.6.0-dev.5](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.4...v2.6.0-dev.5) (2026-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Dashboard padding ([5f757b6](https://github.com/ReVanced/revanced-manager/commit/5f757b6ee1fd923afaeffeae6bde12934822c30f))
|
||||
|
||||
# app [2.6.0-dev.4](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.3...v2.6.0-dev.4) (2026-03-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Ignore RichTap vibrator hardware crash ([7856652](https://github.com/ReVanced/revanced-manager/commit/7856652506896a804a57fc085e80c6cf5c1fcba0))
|
||||
|
||||
# app [2.6.0-dev.3](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.2...v2.6.0-dev.3) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Preserve applied patches on patched apps when source patches is deleted ([#3227](https://github.com/ReVanced/revanced-manager/issues/3227)) ([4884fdb](https://github.com/ReVanced/revanced-manager/commit/4884fdb5545134181a8a2176c7a21911aa22be9b))
|
||||
|
||||
# app [2.6.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.1...v2.6.0-dev.2) (2026-03-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adjust font size and fix letter spacing in announcements ([e1660a7](https://github.com/ReVanced/revanced-manager/commit/e1660a7f2f94f3f50ee8bb2479888af8b760e51e))
|
||||
|
||||
# app [2.6.0-dev.1](https://github.com/ReVanced/revanced-manager/compare/v2.5.1...v2.6.0-dev.1) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Better styling for announcements ([4f88357](https://github.com/ReVanced/revanced-manager/commit/4f88357d9f2aa44bcab980e2719f3d28d169de85))
|
||||
|
||||
## app [2.5.1](https://github.com/ReVanced/revanced-manager/compare/v2.5.0...v2.5.1) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
@@ -179,10 +179,6 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
resValue("string", "app_name", "ReVanced Manager (Debug)")
|
||||
}
|
||||
|
||||
release {
|
||||
// Causes patching to not work properly, if enabled.
|
||||
isMinifyEnabled = false
|
||||
@@ -191,10 +187,7 @@ android {
|
||||
val keystoreFile = file("keystore.jks")
|
||||
|
||||
if (project.hasProperty("signAsDebug") || !keystoreFile.exists()) {
|
||||
resValue("string", "app_name", "ReVanced Manager (Debug signed)")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
|
||||
isPseudoLocalesEnabled = true
|
||||
} else {
|
||||
signingConfig = signingConfigs.create("release") {
|
||||
storeFile = keystoreFile
|
||||
|
||||
@@ -1 +1 @@
|
||||
version = 2.5.1
|
||||
version = 2.6.0-dev.7
|
||||
|
||||
471
app/schemas/app.revanced.manager.data.room.AppDatabase/5.json
Normal file
471
app/schemas/app.revanced.manager.data.room.AppDatabase/5.json
Normal file
@@ -0,0 +1,471 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "00e80220281511a934e0dee1fbd8b3c9",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "patch_bundles",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, PRIMARY KEY(`uid`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionHash",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoUpdate",
|
||||
"columnName": "auto_update",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releasedAt",
|
||||
"columnName": "released_at",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "patch_selections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "patchBundle",
|
||||
"columnName": "patch_bundle",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_patch_selections_patch_bundle_package_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"patch_bundle",
|
||||
"package_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "patch_bundles",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"patch_bundle"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "selected_patches",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`selection` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`selection`, `patch_name`), FOREIGN KEY(`selection`) REFERENCES `patch_selections`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "selection",
|
||||
"columnName": "selection",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "patchName",
|
||||
"columnName": "patch_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"selection",
|
||||
"patch_name"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "patch_selections",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"selection"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "downloaded_app",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "directory",
|
||||
"columnName": "directory",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsed",
|
||||
"columnName": "last_used",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"version"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "installed_app",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`current_package_name` TEXT NOT NULL, `original_package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `install_type` TEXT NOT NULL, PRIMARY KEY(`current_package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "currentPackageName",
|
||||
"columnName": "current_package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "originalPackageName",
|
||||
"columnName": "original_package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "installType",
|
||||
"columnName": "install_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"current_package_name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "applied_patch",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bundle",
|
||||
"columnName": "bundle",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "patchName",
|
||||
"columnName": "patch_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"bundle",
|
||||
"patch_name"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "installed_app",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"package_name"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"current_package_name"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "installed_patch_bundle",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle_uid` INTEGER NOT NULL, `bundle_name` TEXT NOT NULL, `bundle_version` TEXT, PRIMARY KEY(`package_name`, `bundle_uid`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bundleUid",
|
||||
"columnName": "bundle_uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bundleName",
|
||||
"columnName": "bundle_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bundleVersion",
|
||||
"columnName": "bundle_version",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"bundle_uid"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "installed_app",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"package_name"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"current_package_name"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "option_groups",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "patchBundle",
|
||||
"columnName": "patch_bundle",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_option_groups_patch_bundle_package_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"patch_bundle",
|
||||
"package_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "patch_bundles",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"patch_bundle"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "options",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "group",
|
||||
"columnName": "group",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "patchName",
|
||||
"columnName": "patch_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"group",
|
||||
"patch_name",
|
||||
"key"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "option_groups",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"group"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "downloaders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, PRIMARY KEY(`uid`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionHash",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoUpdate",
|
||||
"columnName": "auto_update",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releasedAt",
|
||||
"columnName": "released_at",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"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, '00e80220281511a934e0dee1fbd8b3c9')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import kotlin.random.Random
|
||||
|
||||
@Database(
|
||||
entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, InstalledPatchBundle::class, OptionGroup::class, Option::class, DownloaderEntity::class],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
@@ -35,7 +35,8 @@ import kotlin.random.Random
|
||||
to = 3,
|
||||
spec = AppDatabase.DeleteTrustedDownloaders::class
|
||||
),
|
||||
AutoMigration(from = 3, to = 4)
|
||||
AutoMigration(from = 3, to = 4),
|
||||
AutoMigration(from = 4, to = 5)
|
||||
]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
||||
@@ -4,8 +4,7 @@ import android.os.Parcelable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import app.revanced.manager.data.room.bundles.PatchBundleEntity
|
||||
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@@ -18,15 +17,8 @@ import kotlinx.parcelize.Parcelize
|
||||
parentColumns = ["current_package_name"],
|
||||
childColumns = ["package_name"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
PatchBundleEntity::class,
|
||||
parentColumns = ["uid"],
|
||||
childColumns = ["bundle"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["bundle"], unique = false)]
|
||||
]
|
||||
)
|
||||
data class AppliedPatch(
|
||||
@ColumnInfo(name = "package_name") val packageName: String,
|
||||
|
||||
@@ -37,4 +37,6 @@ class PreferencesManager(
|
||||
val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable)
|
||||
|
||||
val allowMeteredNetworks = booleanPreference("allow_metered_networks", false)
|
||||
|
||||
val pinnedApps = stringSetPreference("pinned_apps", emptySet())
|
||||
}
|
||||
|
||||
@@ -5,21 +5,27 @@ import androidx.compose.animation.expandIn
|
||||
import androidx.compose.animation.shrinkOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.SelectableChipColors
|
||||
import androidx.compose.material3.SelectableChipElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.HapticFeedbackConstantsCompat
|
||||
import app.revanced.manager.util.withHapticFeedback
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CheckedFilterChip(
|
||||
selected: Boolean,
|
||||
@@ -34,30 +40,33 @@ fun CheckedFilterChip(
|
||||
border: BorderStroke? = FilterChipDefaults.filterChipBorder(enabled, selected),
|
||||
interactionSource: MutableInteractionSource? = null
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.CONFIRM),
|
||||
label = label,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
leadingIcon = {
|
||||
AnimatedVisibility(
|
||||
visible = selected,
|
||||
enter = expandIn(expandFrom = Alignment.CenterStart),
|
||||
exit = shrinkOut(shrinkTowards = Alignment.CenterStart)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(FilterChipDefaults.IconSize),
|
||||
imageVector = Icons.Filled.Done,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = trailingIcon,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
elevation = elevation,
|
||||
border = border,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
// Override default Material3 minimum touch target size to nothing
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.CONFIRM),
|
||||
label = label,
|
||||
modifier = modifier.height(32.dp),
|
||||
enabled = enabled,
|
||||
leadingIcon = {
|
||||
AnimatedVisibility(
|
||||
visible = selected,
|
||||
enter = expandIn(expandFrom = Alignment.CenterStart),
|
||||
exit = shrinkOut(shrinkTowards = Alignment.CenterStart)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(FilterChipDefaults.IconSize),
|
||||
imageVector = Icons.Filled.Done,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = trailingIcon,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
elevation = elevation,
|
||||
border = border,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +1,111 @@
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private inline fun <T> NumberInputDialog(
|
||||
current: T?,
|
||||
name: String,
|
||||
unit: String?,
|
||||
crossinline onSubmit: (T?) -> Unit,
|
||||
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
|
||||
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
|
||||
) {
|
||||
var fieldValue by rememberSaveable {
|
||||
mutableStateOf(current?.toString().orEmpty())
|
||||
}
|
||||
val numberFieldValue by remember {
|
||||
derivedStateOf { fieldValue.toNumberOrNull() }
|
||||
}
|
||||
val validatorFailed by remember {
|
||||
derivedStateOf { numberFieldValue?.let { !validator(it) } ?: false }
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onSubmit(null) },
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
isError = validatorFailed,
|
||||
suffix = unit?.let { { Text(it) } },
|
||||
supportingText = {
|
||||
if (validatorFailed) {
|
||||
Text(
|
||||
stringResource(R.string.input_dialog_value_invalid),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { numberFieldValue?.let(onSubmit) },
|
||||
enabled = numberFieldValue != null && !validatorFailed,
|
||||
shapes = ButtonDefaults.shapes()
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onSubmit(null) }, shapes = ButtonDefaults.shapes()) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IntInputDialog(
|
||||
current: Int?,
|
||||
name: String,
|
||||
unit: String? = null,
|
||||
validator: (Int) -> Boolean = { true },
|
||||
onSubmit: (Int?) -> Unit
|
||||
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toIntOrNull)
|
||||
|
||||
@Composable
|
||||
fun LongInputDialog(
|
||||
current: Long?,
|
||||
name: String,
|
||||
unit: String? = null,
|
||||
validator: (Long) -> Boolean = { true },
|
||||
onSubmit: (Long?) -> Unit
|
||||
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toLongOrNull)
|
||||
|
||||
@Composable
|
||||
fun FloatInputDialog(
|
||||
current: Float?,
|
||||
name: String,
|
||||
unit: String? = null,
|
||||
validator: (Float) -> Boolean = { true },
|
||||
onSubmit: (Float?) -> Unit
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import app.revanced.manager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private inline fun <T> NumberInputDialog(
|
||||
current: T?,
|
||||
name: String,
|
||||
unit: String?,
|
||||
crossinline onSubmit: (T?) -> Unit,
|
||||
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
|
||||
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
|
||||
) {
|
||||
var fieldValue by rememberSaveable {
|
||||
mutableStateOf(current?.toString().orEmpty())
|
||||
}
|
||||
val numberFieldValue by remember {
|
||||
derivedStateOf { fieldValue.toNumberOrNull() }
|
||||
}
|
||||
val validatorFailed by remember {
|
||||
derivedStateOf { numberFieldValue?.let { !validator(it) } ?: false }
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onSubmit(null) },
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
isError = validatorFailed,
|
||||
suffix = unit?.let { { Text(it) } },
|
||||
supportingText = {
|
||||
if (validatorFailed) {
|
||||
Text(
|
||||
stringResource(R.string.input_dialog_value_invalid),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { numberFieldValue?.let(onSubmit) },
|
||||
enabled = numberFieldValue != null && !validatorFailed,
|
||||
shapes = ButtonDefaults.shapes()
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onSubmit(null) }, shapes = ButtonDefaults.shapes()) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IntInputDialog(
|
||||
current: Int?,
|
||||
name: String,
|
||||
unit: String? = null,
|
||||
validator: (Int) -> Boolean = { true },
|
||||
onSubmit: (Int?) -> Unit
|
||||
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toIntOrNull)
|
||||
|
||||
@Composable
|
||||
fun LongInputDialog(
|
||||
current: Long?,
|
||||
name: String,
|
||||
unit: String? = null,
|
||||
validator: (Long) -> Boolean = { true },
|
||||
onSubmit: (Long?) -> Unit
|
||||
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toLongOrNull)
|
||||
|
||||
@Composable
|
||||
fun FloatInputDialog(
|
||||
current: Float?,
|
||||
name: String,
|
||||
unit: String? = null,
|
||||
validator: (Float) -> Boolean = { true },
|
||||
onSubmit: (Float?) -> Unit
|
||||
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toFloatOrNull)
|
||||
@@ -0,0 +1,42 @@
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.revanced.manager.R
|
||||
import com.eygraber.compose.placeholder.placeholder
|
||||
|
||||
@Composable
|
||||
fun SurfaceChip(
|
||||
text: String? = null
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.height(24.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text ?: stringResource(R.string.loading),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.placeholder(
|
||||
visible = text == null,
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,62 @@
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun TextInputDialog(
|
||||
initial: String,
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
validator: (String) -> Boolean = String::isNotEmpty,
|
||||
) {
|
||||
val (value, setValue) = rememberSaveable(initial) {
|
||||
mutableStateOf(initial)
|
||||
}
|
||||
val valid = remember(value, validator) {
|
||||
validator(value)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(value) },
|
||||
enabled = valid,
|
||||
shapes = ButtonDefaults.shapes()
|
||||
) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
TextField(value = value, onValueChange = setValue)
|
||||
}
|
||||
)
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun TextInputDialog(
|
||||
initial: String,
|
||||
title: String,
|
||||
placeholder: String? = null,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
validator: (String) -> Boolean = String::isNotEmpty,
|
||||
trailingIcon: @Composable ((value: String, onValueChange: (String) -> Unit) -> Unit)? = null,
|
||||
) {
|
||||
val (value, setValue) = rememberSaveable(initial) {
|
||||
mutableStateOf(initial)
|
||||
}
|
||||
val valid = remember(value, validator) {
|
||||
validator(value)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(value) },
|
||||
enabled = valid,
|
||||
shapes = ButtonDefaults.shapes()
|
||||
) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = setValue,
|
||||
placeholder = placeholder?.let { { Text(placeholder) } },
|
||||
trailingIcon = trailingIcon?.let { { it(value, setValue) } }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,74 +1,134 @@
|
||||
package app.revanced.manager.ui.component.patches
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.FullscreenDialog
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.TooltipIconButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OptionsDialog(
|
||||
patch: PatchInfo,
|
||||
values: Map<String, Any?>?,
|
||||
reset: () -> Unit,
|
||||
set: (String, Any?) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
selectionWarningEnabled: Boolean,
|
||||
readOnly: Boolean
|
||||
) = FullscreenDialog(onDismissRequest = onDismissRequest) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = patch.name,
|
||||
onBackClick = onDismissRequest,
|
||||
actions = {
|
||||
if (!readOnly) {
|
||||
TooltipIconButton(
|
||||
onClick = reset,
|
||||
tooltip = stringResource(R.string.reset)
|
||||
) {
|
||||
Icon(Icons.Filled.Restore, stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
if (patch.options == null) return@LazyColumnWithScrollbar
|
||||
|
||||
items(patch.options, key = { it.name }) { option ->
|
||||
val name = option.name
|
||||
val value =
|
||||
if (values == null || !values.contains(name)) option.default else values[name]
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OptionItem(
|
||||
option = option as Option<Any>,
|
||||
value = value,
|
||||
setValue = {
|
||||
set(name, it)
|
||||
},
|
||||
selectionWarningEnabled = selectionWarningEnabled,
|
||||
readOnly = readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package app.revanced.manager.ui.component.patches
|
||||
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.FullscreenDialog
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.TooltipIconButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OptionsDialog(
|
||||
patch: PatchInfo,
|
||||
values: Map<String, Any?>?,
|
||||
reset: () -> Unit,
|
||||
resetOption: (Option<*>) -> Unit,
|
||||
set: (String, Any?) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
selectionWarningEnabled: Boolean,
|
||||
readOnly: Boolean
|
||||
) {
|
||||
val invalidOptions = remember { mutableStateMapOf<Option<*>, Boolean>() }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDialog) {
|
||||
DiscardInvalidDialog(
|
||||
onDismiss = { showDialog = false },
|
||||
onConfirm = {
|
||||
showDialog = false
|
||||
onDismissRequest()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val onBackClick = {
|
||||
if (invalidOptions.values.any { it }) {
|
||||
showDialog = true
|
||||
} else {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
FullscreenDialog(onDismissRequest = onBackClick) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = patch.name,
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
if (!readOnly) {
|
||||
TooltipIconButton(
|
||||
onClick = reset,
|
||||
tooltip = stringResource(R.string.reset)
|
||||
) {
|
||||
Icon(Icons.Filled.Restore, stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.imePadding()
|
||||
) {
|
||||
patch.options ?: return@LazyColumnWithScrollbar
|
||||
|
||||
items(patch.options, key = { it.name }) { option ->
|
||||
val name = option.name
|
||||
val usingDefault = values == null || name !in values
|
||||
val value = if (usingDefault) option.default else values[name]
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OptionItem(
|
||||
option = option as Option<Any>,
|
||||
value = value,
|
||||
setValue = { set(name, it) },
|
||||
isDefault = usingDefault,
|
||||
reset = { resetOption(option) },
|
||||
selectionWarningEnabled = selectionWarningEnabled,
|
||||
readOnly = readOnly,
|
||||
setInvalid = { invalidOptions[option] = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun DiscardInvalidDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss() },
|
||||
title = { Text(stringResource(R.string.discard_changes_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.patch_options_discard_invalid_description)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm() }, shapes = ButtonDefaults.shapes()) {
|
||||
Text(stringResource(R.string.discard_changes))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onDismiss() }, shapes = ButtonDefaults.shapes()) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ fun PatchesFilterBottomSheet(
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
packageName?.let {
|
||||
CheckedFilterChip(
|
||||
@@ -169,7 +169,7 @@ fun PatchesFilterBottomSheet(
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
packagePatchCounts
|
||||
.toList()
|
||||
|
||||
@@ -49,7 +49,8 @@ fun AnnouncementScreen(
|
||||
scrollState.canScrollBackward || scrollState.canScrollForward
|
||||
}
|
||||
)
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
val headerTextColor = MaterialTheme.colorScheme.onSurface
|
||||
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Scaffold(
|
||||
@@ -120,13 +121,35 @@ fun AnnouncementScreen(
|
||||
val webView = it.children.first() as WebView
|
||||
@Language("HTML")
|
||||
val style = """
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body {
|
||||
line-height: 150%;
|
||||
color: ${textColor.toCss()};
|
||||
}
|
||||
ul, ol {
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
strong, b {
|
||||
font-weight: 600;
|
||||
color: ${headerTextColor.toCss()};
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 475;
|
||||
line-height: 133%;
|
||||
color: ${headerTextColor.toCss()};
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.75em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
a {
|
||||
color: ${linkColor.toCss()};
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.content.ActivityNotFoundException
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -14,10 +16,16 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Apps
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -27,12 +35,16 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -45,21 +57,27 @@ import app.revanced.manager.ui.component.AppLabel
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.ui.component.SearchBar
|
||||
import app.revanced.manager.ui.component.SurfaceChip
|
||||
import app.revanced.manager.ui.component.TooltipIconButton
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.viewmodel.AppsViewModel
|
||||
import app.revanced.manager.util.APK_MIMETYPE
|
||||
import app.revanced.manager.util.AppInfo
|
||||
import app.revanced.manager.util.EventEffect
|
||||
import app.revanced.manager.util.toast
|
||||
import app.revanced.manager.util.transparentListItemColors
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AppsScreen(
|
||||
onAppClick: (InstalledApp) -> Unit,
|
||||
onPatchableAppClick: (String) -> Unit,
|
||||
onStorageSelect: (SelectedApp.Local) -> Unit,
|
||||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
searchLazyListState: LazyListState = rememberLazyListState(),
|
||||
onSearchExpandedChange: (Boolean) -> Unit = {},
|
||||
viewModel: AppsViewModel = koinViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -74,226 +92,142 @@ fun AppsScreen(
|
||||
|
||||
val installedApps by viewModel.installedApps.collectAsStateWithLifecycle()
|
||||
val patchableApps by viewModel.patchableApps.collectAsStateWithLifecycle()
|
||||
val suggestedVersions by viewModel.suggestedVersions.collectAsStateWithLifecycle()
|
||||
val pinnedApps by viewModel.pinnedApps.collectAsStateWithLifecycle()
|
||||
|
||||
fun patchedPackageNames(apps: List<InstalledApp>?): Set<String> =
|
||||
apps
|
||||
?.flatMap { listOf(it.currentPackageName, it.originalPackageName) }
|
||||
?.toSet()
|
||||
val patchedPackageNames =
|
||||
installedApps?.flatMap { listOf(it.currentPackageName, it.originalPackageName) }?.toSet()
|
||||
.orEmpty()
|
||||
|
||||
fun InstalledApp.matchesQuery(query: String): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
|
||||
val packageInfo = viewModel.packageInfoMap[currentPackageName]
|
||||
return currentPackageName.contains(query, ignoreCase = true) ||
|
||||
originalPackageName.contains(query, ignoreCase = true) ||
|
||||
viewModel.loadLabel(packageInfo).contains(query, ignoreCase = true)
|
||||
return currentPackageName.contains(
|
||||
query, ignoreCase = true
|
||||
) || originalPackageName.contains(query, ignoreCase = true) || viewModel.loadLabel(
|
||||
packageInfo
|
||||
).contains(query, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun patchableMatchesQuery(packageName: String, label: String?, query: String): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
|
||||
return packageName.contains(query, ignoreCase = true) ||
|
||||
label?.contains(query, ignoreCase = true) == true
|
||||
return packageName.contains(query, ignoreCase = true) || label?.contains(
|
||||
query, ignoreCase = true
|
||||
) == true
|
||||
}
|
||||
|
||||
var searchExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(searchExpanded) {
|
||||
onSearchExpandedChange(searchExpanded)
|
||||
}
|
||||
val filterText by viewModel.filterText.collectAsStateWithLifecycle()
|
||||
|
||||
val TITLE_HORIZONTAL = 16.dp
|
||||
val TITLE_VERTICAL = 8.dp
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Box(modifier = Modifier.padding(horizontal = if (searchExpanded) 0.dp else 16.dp)) {
|
||||
SearchBar(
|
||||
query = filterText,
|
||||
onQueryChange = viewModel::setFilterText,
|
||||
expanded = searchExpanded,
|
||||
onExpandedChange = { searchExpanded = it },
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
leadingIcon = {
|
||||
Scaffold(topBar = {
|
||||
Box(modifier = Modifier.padding(horizontal = if (searchExpanded) 0.dp else 16.dp)) {
|
||||
SearchBar(
|
||||
query = filterText,
|
||||
onQueryChange = viewModel::setFilterText,
|
||||
expanded = searchExpanded,
|
||||
onExpandedChange = { searchExpanded = it },
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
leadingIcon = {
|
||||
TooltipIconButton(
|
||||
onClick = {
|
||||
if (searchExpanded) {
|
||||
searchExpanded = false
|
||||
viewModel.setFilterText("")
|
||||
}
|
||||
},
|
||||
tooltip = if (searchExpanded) stringResource(R.string.back) else stringResource(
|
||||
R.string.search
|
||||
),
|
||||
) { _ ->
|
||||
Crossfade(
|
||||
targetState = searchExpanded, label = "SearchIcon"
|
||||
) { expanded ->
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.AutoMirrored.Filled.ArrowBack else Icons.Outlined.Search,
|
||||
contentDescription = if (expanded) stringResource(R.string.back) else stringResource(
|
||||
R.string.search
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchExpanded && filterText.isNotEmpty()) {
|
||||
TooltipIconButton(
|
||||
onClick = {
|
||||
if (searchExpanded) {
|
||||
searchExpanded = false
|
||||
viewModel.setFilterText("")
|
||||
}
|
||||
},
|
||||
tooltip = if (searchExpanded) stringResource(R.string.back) else stringResource(R.string.search),
|
||||
) { _ ->
|
||||
Crossfade(
|
||||
targetState = searchExpanded,
|
||||
label = "SearchIcon"
|
||||
) { expanded ->
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.AutoMirrored.Filled.ArrowBack else Icons.Outlined.Search,
|
||||
contentDescription = if (expanded) stringResource(R.string.back) else stringResource(R.string.search)
|
||||
)
|
||||
}
|
||||
onClick = { viewModel.setFilterText("") },
|
||||
tooltip = stringResource(R.string.clear),
|
||||
) { contentDescription ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchExpanded && filterText.isNotEmpty()) {
|
||||
TooltipIconButton(
|
||||
onClick = { viewModel.setFilterText("") },
|
||||
tooltip = stringResource(R.string.clear),
|
||||
) { contentDescription ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
) {
|
||||
val query = filterText.trim()
|
||||
val patched = installedApps
|
||||
val patchable = patchableApps
|
||||
val filteredPatchedApps = patched?.filter { it.matchesQuery(query) }.orEmpty()
|
||||
val filteredPatchableApps = patchable?.filter { app ->
|
||||
app.packageName !in patchedPackageNames && patchableMatchesQuery(
|
||||
packageName = app.packageName,
|
||||
label = viewModel.loadLabel(app.packageInfo),
|
||||
query = query
|
||||
)
|
||||
}.orEmpty()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
val query = filterText.trim()
|
||||
val patched = installedApps
|
||||
val patchable = patchableApps
|
||||
val patchedPkgNames = patchedPackageNames(patched)
|
||||
val filteredPatchedApps = patched
|
||||
?.filter { it.matchesQuery(query) }
|
||||
.orEmpty()
|
||||
val filteredPatchableApps = patchable
|
||||
?.filter { app ->
|
||||
app.packageName !in patchedPkgNames &&
|
||||
patchableMatchesQuery(
|
||||
packageName = app.packageName,
|
||||
label = viewModel.loadLabel(app.packageInfo),
|
||||
query = query
|
||||
)
|
||||
if (patched == null || patchable == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
.orEmpty()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
if (patched == null || patchable == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
|
||||
if (filteredPatchedApps.isNotEmpty()) {
|
||||
item(key = "SEARCH_HEADER_PATCHED") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patched_apps_section_title),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = filteredPatchedApps,
|
||||
key = { "SEARCH_PATCHED-${it.currentPackageName}" },
|
||||
contentType = { "SEARCH_PATCHED" }
|
||||
) { installedApp ->
|
||||
val packageInfo = viewModel.packageInfoMap[installedApp.currentPackageName]
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
searchExpanded = false
|
||||
viewModel.setFilterText("")
|
||||
onAppClick(installedApp)
|
||||
},
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo = packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
AppLabel(packageInfo, defaultText = installedApp.currentPackageName)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(installedApp.currentPackageName)
|
||||
},
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
|
||||
if (filteredPatchableApps.isNotEmpty()) {
|
||||
item(key = "SEARCH_HEADER_PATCHABLE") {
|
||||
Text(
|
||||
text = stringResource(R.string.patchable_apps_section_title),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = filteredPatchableApps,
|
||||
key = { "SEARCH_PATCHABLE-${it.packageName}" },
|
||||
contentType = { "SEARCH_PATCHABLE" }
|
||||
) { app ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
searchExpanded = false
|
||||
viewModel.setFilterText("")
|
||||
onPatchableAppClick(app.packageName)
|
||||
},
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo = app.packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
AppLabel(app.packageInfo, defaultText = app.packageName)
|
||||
},
|
||||
supportingContent = app.patches?.let { patchCount ->
|
||||
{
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.patch_count,
|
||||
patchCount,
|
||||
patchCount
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = if (app.packageInfo == null) {
|
||||
{ Text(stringResource(R.string.not_installed)) }
|
||||
} else null,
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize(), state = searchLazyListState
|
||||
) {
|
||||
appItems(
|
||||
items = buildAppList(
|
||||
filteredPatchedApps, filteredPatchableApps, pinnedApps
|
||||
),
|
||||
viewModel = viewModel,
|
||||
suggestedVersions = suggestedVersions,
|
||||
onPatchedClick = {
|
||||
searchExpanded = false
|
||||
viewModel.setFilterText(""); onAppClick(it)
|
||||
},
|
||||
onPatchableClick = {
|
||||
searchExpanded = false
|
||||
viewModel.setFilterText(""); onPatchableAppClick(it)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
}) { paddingValues ->
|
||||
if (searchExpanded) return@Scaffold
|
||||
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(top = TITLE_VERTICAL),
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
.padding(top = 8.dp),
|
||||
state = lazyListState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
@@ -303,8 +237,7 @@ fun AppsScreen(
|
||||
if (patched == null || patchable == null) {
|
||||
item(key = "LOADING") {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
@@ -312,75 +245,21 @@ fun AppsScreen(
|
||||
return@LazyColumnWithScrollbar
|
||||
}
|
||||
|
||||
val patchedPackageNames = patchedPackageNames(patched)
|
||||
val visiblePatchableApps = patchable.filter { it.packageName !in patchedPackageNames }
|
||||
|
||||
if (patched.isNotEmpty()) {
|
||||
item(key = "HEADER_PATCHED") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patched_apps_section_title),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = patched,
|
||||
key = { "PATCHED-${it.currentPackageName}" },
|
||||
contentType = { "PATCHED" },
|
||||
) { installedApp ->
|
||||
val packageInfo = viewModel.packageInfoMap[installedApp.currentPackageName]
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(installedApp) },
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = { AppLabel(packageInfo, defaultText = null) },
|
||||
supportingContent = { Text(installedApp.currentPackageName) },
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (patched.isNotEmpty()) {
|
||||
item(key = "HEADER_PATCHABLE") {
|
||||
Text(
|
||||
text = stringResource(R.string.patchable_apps_section_title),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL)
|
||||
)
|
||||
}
|
||||
}
|
||||
val allPatchableApps = patchable.filter { it.packageName !in patchedPackageNames }
|
||||
|
||||
item(key = "PATCHABLE_STORAGE") {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { try {
|
||||
pickApkLauncher.launch(APK_MIMETYPE)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
context.toast(R.string.no_file_picker_found)
|
||||
} },
|
||||
modifier = Modifier.clickable {
|
||||
try {
|
||||
pickApkLauncher.launch(APK_MIMETYPE)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
context.toast(R.string.no_file_picker_found)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Box(Modifier.size(36.dp), Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Default.Storage,
|
||||
null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
Icons.Default.Storage, null, modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -392,43 +271,184 @@ fun AppsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = visiblePatchableApps,
|
||||
key = { "PATCHABLE-${it.packageName}" },
|
||||
contentType = { "PATCHABLE" },
|
||||
) { app ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onPatchableAppClick(app.packageName) },
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo = app.packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
AppLabel(
|
||||
app.packageInfo,
|
||||
defaultText = app.packageName
|
||||
)
|
||||
},
|
||||
supportingContent = app.patches?.let { patchCount ->
|
||||
{
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.patch_count,
|
||||
patchCount,
|
||||
patchCount
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = if (app.packageInfo == null) {
|
||||
{ Text(stringResource(R.string.not_installed)) }
|
||||
} else null,
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
appItems(
|
||||
items = buildAppList(patched, allPatchableApps, pinnedApps),
|
||||
viewModel = viewModel,
|
||||
suggestedVersions = suggestedVersions,
|
||||
onPatchedClick = onAppClick,
|
||||
onPatchableClick = onPatchableAppClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface AppListItem {
|
||||
data class Patched(val app: InstalledApp) : AppListItem
|
||||
data class Patchable(val app: AppInfo) : AppListItem
|
||||
data object PinnedHeader : AppListItem
|
||||
data object AvailableHeader : AppListItem
|
||||
}
|
||||
|
||||
private fun buildAppList(
|
||||
patchedApps: List<InstalledApp>, patchableApps: List<AppInfo>, pinnedApps: Set<String>
|
||||
): List<AppListItem> = buildList {
|
||||
val (pinnedPatched, unpinnedPatched) = patchedApps.partition { it.currentPackageName in pinnedApps }
|
||||
val (pinnedPatchable, unpinnedPatchable) = patchableApps.partition { it.packageName in pinnedApps }
|
||||
|
||||
if (pinnedPatched.isNotEmpty() || pinnedPatchable.isNotEmpty()) {
|
||||
add(AppListItem.PinnedHeader)
|
||||
pinnedPatched.mapTo(this) { AppListItem.Patched(it) }
|
||||
pinnedPatchable.mapTo(this) { AppListItem.Patchable(it) }
|
||||
if (unpinnedPatched.isNotEmpty() || unpinnedPatchable.isNotEmpty()) {
|
||||
add(AppListItem.AvailableHeader)
|
||||
}
|
||||
}
|
||||
|
||||
unpinnedPatched.mapTo(this) { AppListItem.Patched(it) }
|
||||
unpinnedPatchable.mapTo(this) { AppListItem.Patchable(it) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun LazyListScope.appItems(
|
||||
items: List<AppListItem>,
|
||||
viewModel: AppsViewModel,
|
||||
suggestedVersions: Map<String, String?>,
|
||||
onPatchedClick: (InstalledApp) -> Unit,
|
||||
onPatchableClick: (String) -> Unit
|
||||
) {
|
||||
items(
|
||||
items = items,
|
||||
key = {
|
||||
when (it) {
|
||||
is AppListItem.Patched -> "PATCHED-${it.app.currentPackageName}"
|
||||
is AppListItem.Patchable -> "PATCHABLE-${it.app.packageName}"
|
||||
AppListItem.PinnedHeader -> "HEADER_PINNED"
|
||||
AppListItem.AvailableHeader -> "HEADER_AVAILABLE"
|
||||
}
|
||||
},
|
||||
contentType = { it::class.simpleName }
|
||||
) { item ->
|
||||
when (item) {
|
||||
is AppListItem.PinnedHeader -> SectionHeader(
|
||||
icon = Icons.Default.PushPin,
|
||||
title = stringResource(R.string.pinned_apps_section_title),
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
|
||||
is AppListItem.AvailableHeader -> SectionHeader(
|
||||
icon = Icons.Default.Apps,
|
||||
title = stringResource(R.string.available_apps_section_title),
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
|
||||
is AppListItem.Patched -> AppItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
onClick = { onPatchedClick(item.app) },
|
||||
onLongClick = { viewModel.togglePinned(item.app.currentPackageName) },
|
||||
packageName = item.app.currentPackageName,
|
||||
packageInfo = viewModel.packageInfoMap[item.app.currentPackageName],
|
||||
isPatched = true,
|
||||
)
|
||||
|
||||
is AppListItem.Patchable -> AppItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
onClick = { onPatchableClick(item.app.packageName) },
|
||||
onLongClick = { viewModel.togglePinned(item.app.packageName) },
|
||||
packageInfo = item.app.packageInfo,
|
||||
packageName = item.app.packageName,
|
||||
patchCount = item.app.patches,
|
||||
suggestedVersion = suggestedVersions[item.app.packageName]
|
||||
?: stringResource(R.string.any_version),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
icon: ImageVector, title: String, modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.rotate(45f),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppItem(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
packageName: String,
|
||||
packageInfo: android.content.pm.PackageInfo?,
|
||||
isPatched: Boolean = false,
|
||||
patchCount: Int? = null,
|
||||
suggestedVersion: String? = null,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo = packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isPatched) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
AppLabel(packageInfo, defaultText = packageName)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
patchCount?.let {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (patchCount > 0) SurfaceChip(
|
||||
pluralStringResource(
|
||||
R.plurals.patch_count,
|
||||
patchCount,
|
||||
patchCount
|
||||
)
|
||||
)
|
||||
if (suggestedVersion != null) SurfaceChip(suggestedVersion)
|
||||
}
|
||||
} ?: Text(packageName)
|
||||
},
|
||||
trailingContent = if (packageInfo == null) {
|
||||
{ Text(stringResource(R.string.not_installed)) }
|
||||
} else null,
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -26,11 +27,13 @@ import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Update
|
||||
@@ -153,6 +156,10 @@ fun DashboardScreen(
|
||||
initialPageOffsetFraction = 0f
|
||||
) { DashboardPage.entries.size }
|
||||
|
||||
val appsLazyListState = rememberLazyListState()
|
||||
val appsSearchLazyListState = rememberLazyListState()
|
||||
var appsSearchExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val dashboardPatchesParams = remember {
|
||||
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
|
||||
app = SelectedApp.Search("", null),
|
||||
@@ -389,12 +396,24 @@ fun DashboardScreen(
|
||||
},
|
||||
containerColor = Color.Transparent,
|
||||
floatingActionButton = {
|
||||
val currentScrollState =
|
||||
if (appsSearchExpanded) appsSearchLazyListState else appsLazyListState
|
||||
val showBackToTop by remember(currentScrollState) {
|
||||
derivedStateOf { currentScrollState.firstVisibleItemIndex > 0 }
|
||||
}
|
||||
|
||||
DashboardFab(
|
||||
pagerState = pagerState,
|
||||
patchesSourceEditMode = patchesSourceEditMode,
|
||||
onEnablePatchesSourceEditMode = { patchesSourceEditMode = true },
|
||||
onAddBundleClick = {
|
||||
showAddBundleDialog = true
|
||||
},
|
||||
showScrollToTop = showBackToTop,
|
||||
onScrollToTop = {
|
||||
composableScope.launch {
|
||||
currentScrollState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -474,7 +493,10 @@ fun DashboardScreen(
|
||||
AppsScreen(
|
||||
onAppClick = { onAppClick(it.currentPackageName) },
|
||||
onPatchableAppClick = ::onPatchableSelection,
|
||||
onStorageSelect = { selectedApp -> onStorageSelection(selectedApp) }
|
||||
onStorageSelect = { selectedApp -> onStorageSelection(selectedApp)},
|
||||
lazyListState = appsLazyListState,
|
||||
searchLazyListState = appsSearchLazyListState,
|
||||
onSearchExpandedChange = { appsSearchExpanded = it }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,9 +541,15 @@ private fun DashboardFab(
|
||||
pagerState: PagerState,
|
||||
patchesSourceEditMode: Boolean,
|
||||
onEnablePatchesSourceEditMode: () -> Unit,
|
||||
onAddBundleClick: () -> Unit
|
||||
onAddBundleClick: () -> Unit,
|
||||
showScrollToTop: Boolean,
|
||||
onScrollToTop: () -> Unit
|
||||
) {
|
||||
val fabState = when (pagerState.currentPage) {
|
||||
DashboardPage.DASHBOARD.ordinal -> {
|
||||
if (showScrollToTop) DashboardFabState.ScrollToTop else DashboardFabState.Hidden
|
||||
}
|
||||
|
||||
DashboardPage.BUNDLES.ordinal -> {
|
||||
if (patchesSourceEditMode) DashboardFabState.AddBundles else DashboardFabState.EditBundles
|
||||
}
|
||||
@@ -529,46 +557,69 @@ private fun DashboardFab(
|
||||
else -> DashboardFabState.Hidden
|
||||
}
|
||||
|
||||
if (fabState == DashboardFabState.Hidden) return
|
||||
|
||||
HapticExtendedFloatingActionButton(
|
||||
onClick = if (fabState == DashboardFabState.AddBundles) onAddBundleClick else onEnablePatchesSourceEditMode,
|
||||
tooltip = stringResource(
|
||||
if (fabState == DashboardFabState.AddBundles) R.string.fab_add_patches else R.string.edit
|
||||
),
|
||||
expanded = fabState == DashboardFabState.AddBundles,
|
||||
icon = {
|
||||
AnimatedContent(
|
||||
targetState = fabState,
|
||||
transitionSpec = {
|
||||
(fadeIn(animationSpec = tween(durationMillis = 180, delayMillis = 60)) +
|
||||
scaleIn(animationSpec = tween(durationMillis = 180, delayMillis = 60), initialScale = 0.85f)) togetherWith
|
||||
(fadeOut(animationSpec = tween(durationMillis = 90)) +
|
||||
scaleOut(animationSpec = tween(durationMillis = 90), targetScale = 0.85f))
|
||||
},
|
||||
label = "dashboard_fab_icon_transition"
|
||||
) { state ->
|
||||
when (state) {
|
||||
DashboardFabState.EditBundles -> {
|
||||
Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.edit))
|
||||
}
|
||||
|
||||
DashboardFabState.AddBundles -> {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
}
|
||||
|
||||
DashboardFabState.Hidden -> Unit
|
||||
AnimatedVisibility(
|
||||
visible = fabState != DashboardFabState.Hidden, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut()
|
||||
) {
|
||||
HapticExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
when (fabState) {
|
||||
DashboardFabState.AddBundles -> onAddBundleClick()
|
||||
DashboardFabState.EditBundles -> onEnablePatchesSourceEditMode()
|
||||
DashboardFabState.ScrollToTop -> onScrollToTop()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
},
|
||||
text = { Text(stringResource(R.string.fab_add_patches)) }
|
||||
)
|
||||
},
|
||||
tooltip = stringResource(
|
||||
if (fabState == DashboardFabState.AddBundles) R.string.fab_add_patches else R.string.edit
|
||||
),
|
||||
expanded = fabState == DashboardFabState.AddBundles,
|
||||
icon = {
|
||||
AnimatedContent(
|
||||
targetState = fabState,
|
||||
transitionSpec = {
|
||||
(fadeIn(animationSpec = tween(durationMillis = 180, delayMillis = 60)) +
|
||||
scaleIn(
|
||||
animationSpec = tween(durationMillis = 180, delayMillis = 60),
|
||||
initialScale = 0.85f
|
||||
)) togetherWith
|
||||
(fadeOut(animationSpec = tween(durationMillis = 90)) +
|
||||
scaleOut(
|
||||
animationSpec = tween(durationMillis = 90),
|
||||
targetScale = 0.85f
|
||||
))
|
||||
},
|
||||
label = "dashboard_fab_icon_transition"
|
||||
) { state ->
|
||||
when (state) {
|
||||
DashboardFabState.EditBundles -> {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
contentDescription = stringResource(R.string.edit)
|
||||
)
|
||||
}
|
||||
|
||||
DashboardFabState.AddBundles -> {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
}
|
||||
|
||||
DashboardFabState.ScrollToTop -> {
|
||||
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
},
|
||||
text = { Text(stringResource(R.string.fab_add_patches)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DashboardFabState {
|
||||
Hidden,
|
||||
EditBundles,
|
||||
AddBundles,
|
||||
ScrollToTop
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -269,8 +271,14 @@ fun PatcherScreen(
|
||||
expandedCategory = category
|
||||
}
|
||||
|
||||
val patcherProgress by animateFloatAsState(
|
||||
targetValue = viewModel.progress,
|
||||
animationSpec = tween(),
|
||||
label = "patcherProgress"
|
||||
)
|
||||
|
||||
LinearWavyProgressIndicator(
|
||||
progress = { viewModel.progress },
|
||||
progress = { patcherProgress },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ fun PatchesSelectorScreen(
|
||||
patch = dialog.patch,
|
||||
values = viewModel.getOptions(dialog.bundle, dialog.patch),
|
||||
reset = { viewModel.resetOptions(dialog.bundle, dialog.patch) },
|
||||
resetOption = { viewModel.resetOption(dialog.bundle, dialog.patch, it) },
|
||||
set = { key, value ->
|
||||
viewModel.setOption(
|
||||
dialog.bundle,
|
||||
@@ -230,7 +231,7 @@ fun PatchesSelectorScreen(
|
||||
)
|
||||
},
|
||||
selectionWarningEnabled = viewModel.selectionWarningEnabled,
|
||||
readOnly = readOnly
|
||||
readOnly = readOnly,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,12 +358,7 @@ fun PatchesSelectorScreen(
|
||||
expanded = searchExpanded,
|
||||
onExpandedChange = setSearchExpanded,
|
||||
placeholder = { Text(stringResource(R.string.search_patches)) },
|
||||
windowInsets = if (readOnly) WindowInsets(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
) else WindowInsets.systemBars,
|
||||
windowInsets = if (readOnly) WindowInsets(top = 0, bottom = 0) else WindowInsets.systemBars,
|
||||
leadingIcon = {
|
||||
TooltipIconButton(
|
||||
onClick = {
|
||||
@@ -479,10 +475,16 @@ fun PatchesSelectorScreen(
|
||||
) { paddingValues ->
|
||||
if (searchExpanded) return@Scaffold
|
||||
|
||||
val appliedPadding = if (!readOnly) {
|
||||
paddingValues
|
||||
} else {
|
||||
PaddingValues(top = paddingValues.calculateTopPadding())
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(appliedPadding)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -1,168 +1,170 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
|
||||
import app.revanced.manager.ui.component.haptics.HapticTab
|
||||
import app.revanced.manager.ui.component.patches.OptionItem
|
||||
import app.revanced.manager.ui.component.patches.PatchesListHeader
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.isScrollingUp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RequiredOptionsScreen(
|
||||
onContinue: (PatchSelection?, Options) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: PatchesSelectorViewModel
|
||||
) {
|
||||
val list by vm.requiredOptsPatches.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
initialPageOffsetFraction = 0f
|
||||
) {
|
||||
list.size
|
||||
}
|
||||
val patchLazyListStates = remember(list) { List(list.size, ::LazyListState) }
|
||||
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(emptyList())
|
||||
val showContinueButton by remember {
|
||||
derivedStateOf {
|
||||
bundles.requiredOptionsSet(
|
||||
allowIncompatible = vm.allowIncompatiblePatches,
|
||||
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
|
||||
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
|
||||
)
|
||||
}
|
||||
}
|
||||
val composableScope = rememberCoroutineScope()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.required_options_screen),
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!showContinueButton) return@Scaffold
|
||||
|
||||
HapticExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.patch)) },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.AutoFixHigh,
|
||||
stringResource(R.string.patch)
|
||||
)
|
||||
},
|
||||
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
|
||||
?: true,
|
||||
onClick = {
|
||||
onContinue(vm.getCustomSelection(), vm.getOptions())
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (list.isEmpty()) return@Column
|
||||
else if (list.size > 1) SecondaryScrollableTabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||
) {
|
||||
list.forEachIndexed { index, (bundle, _) ->
|
||||
HapticTab(
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
composableScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
index
|
||||
)
|
||||
}
|
||||
},
|
||||
text = { Text(bundle.name) },
|
||||
selectedContentColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
pageContent = { index ->
|
||||
// Avoid crashing if the lists have not been fully initialized yet.
|
||||
if (index > list.lastIndex || list.size != patchLazyListStates.size) return@HorizontalPager
|
||||
val (bundle, patches) = list[index]
|
||||
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = patchLazyListStates[index]
|
||||
) {
|
||||
items(patches, key = { it.name }) {
|
||||
PatchesListHeader(it.name)
|
||||
|
||||
val values = vm.getOptions(bundle.uid, it)
|
||||
it.options?.forEach { option ->
|
||||
val name = option.name
|
||||
val value =
|
||||
if (values == null || name !in values) option.default else values[name]
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OptionItem(
|
||||
option = option as Option<Any>,
|
||||
value = value,
|
||||
setValue = { new ->
|
||||
vm.setOption(bundle.uid, it, name, new)
|
||||
},
|
||||
selectionWarningEnabled = vm.selectionWarningEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SecondaryScrollableTabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
|
||||
import app.revanced.manager.ui.component.haptics.HapticTab
|
||||
import app.revanced.manager.ui.component.patches.OptionItem
|
||||
import app.revanced.manager.ui.component.patches.PatchesListHeader
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.isScrollingUp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RequiredOptionsScreen(
|
||||
onContinue: (PatchSelection?, Options) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: PatchesSelectorViewModel
|
||||
) {
|
||||
val list by vm.requiredOptsPatches.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
initialPageOffsetFraction = 0f
|
||||
) {
|
||||
list.size
|
||||
}
|
||||
val patchLazyListStates = remember(list) { List(list.size, ::LazyListState) }
|
||||
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(emptyList())
|
||||
val showContinueButton by remember {
|
||||
derivedStateOf {
|
||||
bundles.requiredOptionsSet(
|
||||
allowIncompatible = vm.allowIncompatiblePatches,
|
||||
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
|
||||
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
|
||||
)
|
||||
}
|
||||
}
|
||||
val composableScope = rememberCoroutineScope()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.required_options_screen),
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!showContinueButton) return@Scaffold
|
||||
|
||||
HapticExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.patch)) },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.AutoFixHigh,
|
||||
stringResource(R.string.patch)
|
||||
)
|
||||
},
|
||||
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
|
||||
?: true,
|
||||
onClick = {
|
||||
onContinue(vm.getCustomSelection(), vm.getOptions())
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (list.isEmpty()) return@Column
|
||||
else if (list.size > 1) SecondaryScrollableTabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||
) {
|
||||
list.forEachIndexed { index, (bundle, _) ->
|
||||
HapticTab(
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
composableScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
index
|
||||
)
|
||||
}
|
||||
},
|
||||
text = { Text(bundle.name) },
|
||||
selectedContentColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
pageContent = { index ->
|
||||
// Avoid crashing if the lists have not been fully initialized yet.
|
||||
if (index > list.lastIndex || list.size != patchLazyListStates.size) return@HorizontalPager
|
||||
val (bundle, patches) = list[index]
|
||||
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = patchLazyListStates[index]
|
||||
) {
|
||||
items(patches, key = { it.name }) {
|
||||
PatchesListHeader(it.name)
|
||||
|
||||
val values = vm.getOptions(bundle.uid, it)
|
||||
it.options?.forEach { option ->
|
||||
val name = option.name
|
||||
val usingDefault = values == null || name !in values
|
||||
val value = if (usingDefault) option.default else values[name]
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OptionItem(
|
||||
option = option as Option<Any>,
|
||||
value = value,
|
||||
setValue = { new -> vm.setOption(bundle.uid, it, name, new) },
|
||||
isDefault = usingDefault,
|
||||
reset = { vm.resetOption(bundle.uid, it, option) },
|
||||
selectionWarningEnabled = vm.selectionWarningEnabled,
|
||||
// Invalid options won't be saved, so we don't need to handle any states here
|
||||
setInvalid = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -113,10 +115,15 @@ fun UpdateScreen(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
) {
|
||||
if (vm.state == State.DOWNLOADING) {
|
||||
val updaterProgress by animateFloatAsState(
|
||||
targetValue = vm.downloadProgress,
|
||||
animationSpec = tween(),
|
||||
label = "updaterProgress"
|
||||
)
|
||||
|
||||
LinearWavyProgressIndicator(
|
||||
progress = { vm.downloadProgress },
|
||||
progress = { updaterProgress },
|
||||
modifier = Modifier
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||
import app.revanced.manager.domain.installer.RootInstaller
|
||||
import app.revanced.manager.domain.installer.RootServiceException
|
||||
import app.revanced.manager.domain.repository.InstalledAppRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.toast
|
||||
@@ -39,6 +41,8 @@ class AppsViewModel(
|
||||
private val installedAppsRepository: InstalledAppRepository,
|
||||
private val pm: PM,
|
||||
private val rootInstaller: RootInstaller,
|
||||
private val patchBundleRepository: PatchBundleRepository,
|
||||
private val prefs: PreferencesManager,
|
||||
fs: Filesystem,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
@@ -56,6 +60,30 @@ class AppsViewModel(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
val suggestedVersions = patchBundleRepository.suggestedVersions.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
val pinnedApps = prefs.pinnedApps.flow.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = prefs.pinnedApps.getBlocking(),
|
||||
)
|
||||
|
||||
fun togglePinned(packageName: String) {
|
||||
viewModelScope.launch {
|
||||
prefs.edit {
|
||||
if (packageName in prefs.pinnedApps.value) {
|
||||
prefs.pinnedApps -= packageName
|
||||
} else {
|
||||
prefs.pinnedApps += packageName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val installedApps = installedAppsRepository.getAll().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
|
||||
@@ -1,396 +1,403 @@
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import app.revanced.manager.R
|
||||
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.PatchBundleInfo
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.saver.Nullable
|
||||
import app.revanced.manager.util.saver.nullableSaver
|
||||
import app.revanced.manager.util.saver.persistentMapSaver
|
||||
import app.revanced.manager.util.saver.persistentSetSaver
|
||||
import app.revanced.manager.util.saver.snapshotStateMapSaver
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.PersistentSet
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
|
||||
ViewModel(), KoinComponent {
|
||||
private val app: Application = get()
|
||||
private val savedStateHandle: SavedStateHandle = get()
|
||||
private val prefs: PreferencesManager = get()
|
||||
private val bundleRepository: PatchBundleRepository = get()
|
||||
|
||||
val readOnly = input.readOnly
|
||||
private val browseAllBundles = input.browseAllBundles
|
||||
val packageName = input.app.packageName
|
||||
val appVersion = input.app.version
|
||||
|
||||
var selectionWarningEnabled by mutableStateOf(true)
|
||||
private set
|
||||
var universalPatchWarningEnabled by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
val allowIncompatiblePatches =
|
||||
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking() || appVersion == null
|
||||
val bundlesFlow = if (browseAllBundles) {
|
||||
combine(bundleRepository.sources, bundleRepository.bundleInfoFlow) { sources, bundles ->
|
||||
mergeSourcesWithBundleInfo(
|
||||
sources,
|
||||
bundles.mapValues { (_, bundle) -> bundle.asReadonlyScoped() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
combine(
|
||||
bundleRepository.sources,
|
||||
bundleRepository.scopedBundleInfoFlow(packageName, input.app.version)
|
||||
) { sources, bundles ->
|
||||
mergeSourcesWithBundleInfo(
|
||||
sources,
|
||||
bundles.associateBy(PatchBundleInfo.Scoped::uid)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val bundleLoadIssuesFlow = bundleRepository.sources.map { sources ->
|
||||
sources.mapNotNull { source ->
|
||||
val messageId = when {
|
||||
source.error != null -> R.string.patches_error_description
|
||||
source.state is State.Missing -> R.string.patches_not_downloaded
|
||||
else -> null
|
||||
} ?: return@mapNotNull null
|
||||
|
||||
source.uid to messageId
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
if (readOnly) {
|
||||
universalPatchWarningEnabled = false
|
||||
selectionWarningEnabled = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (prefs.disableUniversalPatchCheck.get()) {
|
||||
universalPatchWarningEnabled = false
|
||||
}
|
||||
|
||||
if (prefs.disableSelectionWarning.get()) {
|
||||
selectionWarningEnabled = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
fun PatchBundleInfo.Scoped.hasDefaultPatches() =
|
||||
patchSequence(allowIncompatiblePatches).any { it.include }
|
||||
|
||||
// Don't show the warning if there are no default patches.
|
||||
selectionWarningEnabled = bundlesFlow.first().any(PatchBundleInfo.Scoped::hasDefaultPatches)
|
||||
}
|
||||
}
|
||||
|
||||
private var hasModifiedSelection = false
|
||||
var customPatchSelection: PersistentPatchSelection? by savedStateHandle.saveable(
|
||||
key = "selection",
|
||||
stateSaver = selectionSaver,
|
||||
) {
|
||||
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
|
||||
}
|
||||
|
||||
private val patchOptions: PersistentOptions by savedStateHandle.saveable(
|
||||
saver = optionsSaver,
|
||||
) {
|
||||
// Convert Options to PersistentOptions
|
||||
input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) ->
|
||||
allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active dialog or bottom sheet state. Only one can be shown at a time.
|
||||
*/
|
||||
var activeDialog by mutableStateOf<DialogState?>(null)
|
||||
private set
|
||||
|
||||
var filter by mutableIntStateOf(SHOW_UNIVERSAL)
|
||||
private set
|
||||
|
||||
private val defaultPatchSelection = bundlesFlow.map { bundles ->
|
||||
bundles.toPatchSelection(allowIncompatiblePatches) { _, patch -> patch.include }
|
||||
.toPersistentPatchSelection()
|
||||
}
|
||||
|
||||
val defaultSelectionCount = defaultPatchSelection.map { selection ->
|
||||
selection.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
// This is for the required options screen.
|
||||
private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
|
||||
bundlesFlow.first().map { bundle ->
|
||||
bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch ->
|
||||
val opts by lazy {
|
||||
getOptions(bundle.uid, patch).orEmpty()
|
||||
}
|
||||
isSelected(
|
||||
bundle.uid,
|
||||
patch
|
||||
) && patch.options?.any { it.required && it.default == null && it.name !in opts } ?: false
|
||||
}.toList()
|
||||
}.filter { (_, patches) -> patches.isNotEmpty() }
|
||||
}
|
||||
val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) }
|
||||
|
||||
fun selectionIsValid(bundles: List<PatchBundleInfo.Scoped>) = !readOnly && bundles.any { bundle ->
|
||||
bundle.patchSequence(allowIncompatiblePatches).any { patch ->
|
||||
isSelected(bundle.uid, patch)
|
||||
}
|
||||
}
|
||||
|
||||
fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection ->
|
||||
selection[bundle]?.contains(patch.name) == true
|
||||
} ?: patch.include
|
||||
|
||||
fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {
|
||||
hasModifiedSelection = true
|
||||
|
||||
val selection = customPatchSelection ?: defaultPatchSelection.first()
|
||||
val newPatches = selection[bundle]?.let { patches ->
|
||||
if (patch.name in patches)
|
||||
patches.remove(patch.name)
|
||||
else
|
||||
patches.add(patch.name)
|
||||
} ?: persistentSetOf(patch.name)
|
||||
|
||||
customPatchSelection = selection.put(bundle, newPatches)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
patchOptions.clear()
|
||||
customPatchSelection = null
|
||||
hasModifiedSelection = false
|
||||
app.toast(app.getString(R.string.patch_selection_reset_toast))
|
||||
}
|
||||
|
||||
fun getCustomSelection(): PatchSelection? {
|
||||
// Convert persistent collections to standard hash collections because persistent collections are not parcelable.
|
||||
|
||||
return customPatchSelection?.mapValues { (_, v) -> v.toSet() }
|
||||
}
|
||||
|
||||
fun getOptions(): Options {
|
||||
// Convert the collection for the same reasons as in getCustomSelection()
|
||||
|
||||
return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } }
|
||||
}
|
||||
|
||||
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
|
||||
|
||||
fun setOption(bundle: Int, patch: PatchInfo, name: String, value: Any?) {
|
||||
// All patches
|
||||
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
|
||||
// The key-value options of an individual patch
|
||||
val patchToOpts = patchesToOpts
|
||||
.getOrElse(patch.name, ::persistentMapOf)
|
||||
.put(name, value)
|
||||
|
||||
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
|
||||
}
|
||||
|
||||
fun resetOptions(bundle: Int, patch: PatchInfo) {
|
||||
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||
patchOptions[bundle] = patchOptions[bundle]?.remove(patch.name) ?: return
|
||||
}
|
||||
|
||||
fun dismissDialogs() {
|
||||
activeDialog = null
|
||||
}
|
||||
|
||||
fun openIncompatibleDialog(incompatiblePatch: PatchInfo) {
|
||||
val versions = incompatiblePatch.compatiblePackages
|
||||
?.find { it.packageName == packageName }?.versions.orEmpty()
|
||||
activeDialog = DialogState.IncompatiblePatch(versions)
|
||||
}
|
||||
|
||||
fun openOptionsDialog(bundle: Int, patch: PatchInfo) {
|
||||
activeDialog = DialogState.Options(bundle, patch)
|
||||
}
|
||||
|
||||
fun showSelectionWarning() {
|
||||
activeDialog = DialogState.SelectionWarning
|
||||
}
|
||||
|
||||
fun showUniversalPatchWarning() {
|
||||
activeDialog = DialogState.UniversalPatchWarning
|
||||
}
|
||||
|
||||
fun showIncompatiblePatchesInfo() {
|
||||
activeDialog = DialogState.IncompatiblePatchesInfo
|
||||
}
|
||||
|
||||
fun toggleFlag(flag: Int) {
|
||||
filter = filter xor flag
|
||||
}
|
||||
|
||||
fun getBundleSelectionState(bundle: PatchBundleInfo.Scoped): Boolean? {
|
||||
val patches = bundle.patchSequence(allowIncompatiblePatches).toList()
|
||||
if (patches.isEmpty()) return false
|
||||
|
||||
val selectedCount = patches.count { isSelected(bundle.uid, it) }
|
||||
return when (selectedCount) {
|
||||
patches.size -> true
|
||||
0 -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentSelection(): PersistentPatchSelection =
|
||||
customPatchSelection ?: defaultPatchSelection.first()
|
||||
|
||||
private suspend fun updateSelection(
|
||||
update: (PersistentPatchSelection) -> PersistentPatchSelection
|
||||
) {
|
||||
hasModifiedSelection = true
|
||||
customPatchSelection = update(currentSelection())
|
||||
}
|
||||
|
||||
fun deselectAll(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
|
||||
updateSelection { selection ->
|
||||
bundles.fold(selection) { acc, bundle ->
|
||||
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
|
||||
acc.put(bundle.uid, persistentSetOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
|
||||
updateSelection { selection ->
|
||||
bundles.fold(selection) { acc, bundle ->
|
||||
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
|
||||
|
||||
val currentSelected = acc[bundle.uid] ?: persistentSetOf()
|
||||
val inverted = bundle.patchSequence(allowIncompatiblePatches)
|
||||
.filter { it.name !in currentSelected }
|
||||
.map { it.name }
|
||||
.toPersistentSet()
|
||||
acc.put(bundle.uid, inverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreDefaults(bundleUid: Int?) = viewModelScope.launch {
|
||||
if (bundleUid == null) {
|
||||
customPatchSelection = null
|
||||
hasModifiedSelection = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val defaults = defaultPatchSelection.first()
|
||||
updateSelection { selection ->
|
||||
selection.put(bundleUid, defaults[bundleUid] ?: persistentSetOf())
|
||||
}
|
||||
}
|
||||
|
||||
fun deselectAllExcept(bundles: List<PatchBundleInfo.Scoped>, keepBundleUid: Int) = viewModelScope.launch {
|
||||
updateSelection { selection ->
|
||||
bundles.fold(selection) { acc, bundle ->
|
||||
if (bundle.uid == keepBundleUid) return@fold acc
|
||||
acc.put(bundle.uid, persistentSetOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHOW_INCOMPATIBLE = 1 // 2^0
|
||||
const val SHOW_UNIVERSAL = 2 // 2^1
|
||||
|
||||
private val optionsSaver: Saver<PersistentOptions, Options> = snapshotStateMapSaver(
|
||||
// Patch name -> Options
|
||||
valueSaver = persistentMapSaver(
|
||||
// Option key -> Option value
|
||||
valueSaver = persistentMapSaver()
|
||||
)
|
||||
)
|
||||
|
||||
private val selectionSaver: Saver<PersistentPatchSelection?, Nullable<PatchSelection>> =
|
||||
nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver()))
|
||||
}
|
||||
|
||||
sealed interface DialogState {
|
||||
data class Options(val bundle: Int, val patch: PatchInfo) : DialogState
|
||||
data class IncompatiblePatch(val compatibleVersions: Set<String>) : DialogState
|
||||
data object IncompatiblePatchesInfo : DialogState
|
||||
data object SelectionWarning : DialogState
|
||||
data object UniversalPatchWarning : DialogState
|
||||
}
|
||||
|
||||
private fun mergeSourcesWithBundleInfo(
|
||||
sources: List<PatchBundleSource>,
|
||||
scopedBundleInfoByUid: Map<Int, PatchBundleInfo.Scoped>
|
||||
) = sources.map { source ->
|
||||
scopedBundleInfoByUid[source.uid] ?: source.emptyScopedBundleInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// Versions of other types, but utilizing persistent/observable collection types.
|
||||
private typealias PersistentOptions = SnapshotStateMap<Int, PersistentMap<String, PersistentMap<String, Any?>>>
|
||||
private typealias PersistentPatchSelection = PersistentMap<Int, PersistentSet<String>>
|
||||
|
||||
private fun PatchSelection.toPersistentPatchSelection(): PersistentPatchSelection =
|
||||
mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap()
|
||||
|
||||
private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped(
|
||||
name = name,
|
||||
version = version,
|
||||
releasedAt = releasedAt,
|
||||
uid = uid,
|
||||
patches = patches,
|
||||
compatible = patches,
|
||||
incompatible = emptyList(),
|
||||
universal = emptyList()
|
||||
)
|
||||
|
||||
private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped(
|
||||
name = name,
|
||||
version = version,
|
||||
releasedAt = (this.asRemoteOrNull)?.releasedAt,
|
||||
uid = uid,
|
||||
patches = emptyList(),
|
||||
compatible = emptyList(),
|
||||
incompatible = emptyList(),
|
||||
universal = emptyList()
|
||||
)
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import app.revanced.manager.R
|
||||
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
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.saver.Nullable
|
||||
import app.revanced.manager.util.saver.nullableSaver
|
||||
import app.revanced.manager.util.saver.persistentMapSaver
|
||||
import app.revanced.manager.util.saver.persistentSetSaver
|
||||
import app.revanced.manager.util.saver.snapshotStateMapSaver
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.PersistentSet
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
|
||||
ViewModel(), KoinComponent {
|
||||
private val app: Application = get()
|
||||
private val savedStateHandle: SavedStateHandle = get()
|
||||
private val prefs: PreferencesManager = get()
|
||||
private val bundleRepository: PatchBundleRepository = get()
|
||||
|
||||
val readOnly = input.readOnly
|
||||
private val browseAllBundles = input.browseAllBundles
|
||||
val packageName = input.app.packageName
|
||||
val appVersion = input.app.version
|
||||
|
||||
var selectionWarningEnabled by mutableStateOf(true)
|
||||
private set
|
||||
var universalPatchWarningEnabled by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
val allowIncompatiblePatches =
|
||||
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking() || appVersion == null
|
||||
val bundlesFlow = if (browseAllBundles) {
|
||||
combine(bundleRepository.sources, bundleRepository.bundleInfoFlow) { sources, bundles ->
|
||||
mergeSourcesWithBundleInfo(
|
||||
sources,
|
||||
bundles.mapValues { (_, bundle) -> bundle.asReadonlyScoped() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
combine(
|
||||
bundleRepository.sources,
|
||||
bundleRepository.scopedBundleInfoFlow(packageName, input.app.version)
|
||||
) { sources, bundles ->
|
||||
mergeSourcesWithBundleInfo(
|
||||
sources,
|
||||
bundles.associateBy(PatchBundleInfo.Scoped::uid)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val bundleLoadIssuesFlow = bundleRepository.sources.map { sources ->
|
||||
sources.mapNotNull { source ->
|
||||
val messageId = when {
|
||||
source.error != null -> R.string.patches_error_description
|
||||
source.state is State.Missing -> R.string.patches_not_downloaded
|
||||
else -> null
|
||||
} ?: return@mapNotNull null
|
||||
|
||||
source.uid to messageId
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
if (readOnly) {
|
||||
universalPatchWarningEnabled = false
|
||||
selectionWarningEnabled = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (prefs.disableUniversalPatchCheck.get()) {
|
||||
universalPatchWarningEnabled = false
|
||||
}
|
||||
|
||||
if (prefs.disableSelectionWarning.get()) {
|
||||
selectionWarningEnabled = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
fun PatchBundleInfo.Scoped.hasDefaultPatches() =
|
||||
patchSequence(allowIncompatiblePatches).any { it.include }
|
||||
|
||||
// Don't show the warning if there are no default patches.
|
||||
selectionWarningEnabled = bundlesFlow.first().any(PatchBundleInfo.Scoped::hasDefaultPatches)
|
||||
}
|
||||
}
|
||||
|
||||
private var hasModifiedSelection = false
|
||||
var customPatchSelection: PersistentPatchSelection? by savedStateHandle.saveable(
|
||||
key = "selection",
|
||||
stateSaver = selectionSaver,
|
||||
) {
|
||||
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
|
||||
}
|
||||
|
||||
private val patchOptions: PersistentOptions by savedStateHandle.saveable(
|
||||
saver = optionsSaver,
|
||||
) {
|
||||
// Convert Options to PersistentOptions
|
||||
input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) ->
|
||||
allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active dialog or bottom sheet state. Only one can be shown at a time.
|
||||
*/
|
||||
var activeDialog by mutableStateOf<DialogState?>(null)
|
||||
private set
|
||||
|
||||
var filter by mutableIntStateOf(SHOW_UNIVERSAL)
|
||||
private set
|
||||
|
||||
private val defaultPatchSelection = bundlesFlow.map { bundles ->
|
||||
bundles.toPatchSelection(allowIncompatiblePatches) { _, patch -> patch.include }
|
||||
.toPersistentPatchSelection()
|
||||
}
|
||||
|
||||
val defaultSelectionCount = defaultPatchSelection.map { selection ->
|
||||
selection.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
// This is for the required options screen.
|
||||
private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
|
||||
bundlesFlow.first().map { bundle ->
|
||||
bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch ->
|
||||
val opts by lazy {
|
||||
getOptions(bundle.uid, patch).orEmpty()
|
||||
}
|
||||
isSelected(
|
||||
bundle.uid,
|
||||
patch
|
||||
) && patch.options?.any { it.required && it.default == null && it.name !in opts } ?: false
|
||||
}.toList()
|
||||
}.filter { (_, patches) -> patches.isNotEmpty() }
|
||||
}
|
||||
val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) }
|
||||
|
||||
fun selectionIsValid(bundles: List<PatchBundleInfo.Scoped>) = !readOnly && bundles.any { bundle ->
|
||||
bundle.patchSequence(allowIncompatiblePatches).any { patch ->
|
||||
isSelected(bundle.uid, patch)
|
||||
}
|
||||
}
|
||||
|
||||
fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection ->
|
||||
selection[bundle]?.contains(patch.name) == true
|
||||
} ?: patch.include
|
||||
|
||||
fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {
|
||||
hasModifiedSelection = true
|
||||
|
||||
val selection = customPatchSelection ?: defaultPatchSelection.first()
|
||||
val newPatches = selection[bundle]?.let { patches ->
|
||||
if (patch.name in patches)
|
||||
patches.remove(patch.name)
|
||||
else
|
||||
patches.add(patch.name)
|
||||
} ?: persistentSetOf(patch.name)
|
||||
|
||||
customPatchSelection = selection.put(bundle, newPatches)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
patchOptions.clear()
|
||||
customPatchSelection = null
|
||||
hasModifiedSelection = false
|
||||
app.toast(app.getString(R.string.patch_selection_reset_toast))
|
||||
}
|
||||
|
||||
fun getCustomSelection(): PatchSelection? {
|
||||
// Convert persistent collections to standard hash collections because persistent collections are not parcelable.
|
||||
|
||||
return customPatchSelection?.mapValues { (_, v) -> v.toSet() }
|
||||
}
|
||||
|
||||
fun getOptions(): Options {
|
||||
// Convert the collection for the same reasons as in getCustomSelection()
|
||||
|
||||
return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } }
|
||||
}
|
||||
|
||||
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
|
||||
|
||||
fun setOption(bundle: Int, patch: PatchInfo, name: String, value: Any?) {
|
||||
// All patches
|
||||
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
|
||||
// The key-value options of an individual patch
|
||||
val patchToOpts = patchesToOpts
|
||||
.getOrElse(patch.name, ::persistentMapOf)
|
||||
.put(name, value)
|
||||
|
||||
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
|
||||
}
|
||||
|
||||
fun resetOptions(bundle: Int, patch: PatchInfo) {
|
||||
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||
patchOptions[bundle] = patchOptions[bundle]?.remove(patch.name) ?: return
|
||||
}
|
||||
|
||||
fun resetOption(bundle: Int, patch: PatchInfo, option: Option<*>) {
|
||||
val bundlesToPatches = patchOptions[bundle] ?: return
|
||||
val patchesToOpts = bundlesToPatches[patch.name] ?: return
|
||||
patchOptions[bundle] = bundlesToPatches.put(patch.name, patchesToOpts.remove(option.name))
|
||||
}
|
||||
|
||||
fun dismissDialogs() {
|
||||
activeDialog = null
|
||||
}
|
||||
|
||||
fun openIncompatibleDialog(incompatiblePatch: PatchInfo) {
|
||||
val versions = incompatiblePatch.compatiblePackages
|
||||
?.find { it.packageName == packageName }?.versions.orEmpty()
|
||||
activeDialog = DialogState.IncompatiblePatch(versions)
|
||||
}
|
||||
|
||||
fun openOptionsDialog(bundle: Int, patch: PatchInfo) {
|
||||
activeDialog = DialogState.Options(bundle, patch)
|
||||
}
|
||||
|
||||
fun showSelectionWarning() {
|
||||
activeDialog = DialogState.SelectionWarning
|
||||
}
|
||||
|
||||
fun showUniversalPatchWarning() {
|
||||
activeDialog = DialogState.UniversalPatchWarning
|
||||
}
|
||||
|
||||
fun showIncompatiblePatchesInfo() {
|
||||
activeDialog = DialogState.IncompatiblePatchesInfo
|
||||
}
|
||||
|
||||
fun toggleFlag(flag: Int) {
|
||||
filter = filter xor flag
|
||||
}
|
||||
|
||||
fun getBundleSelectionState(bundle: PatchBundleInfo.Scoped): Boolean? {
|
||||
val patches = bundle.patchSequence(allowIncompatiblePatches).toList()
|
||||
if (patches.isEmpty()) return false
|
||||
|
||||
val selectedCount = patches.count { isSelected(bundle.uid, it) }
|
||||
return when (selectedCount) {
|
||||
patches.size -> true
|
||||
0 -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentSelection(): PersistentPatchSelection =
|
||||
customPatchSelection ?: defaultPatchSelection.first()
|
||||
|
||||
private suspend fun updateSelection(
|
||||
update: (PersistentPatchSelection) -> PersistentPatchSelection
|
||||
) {
|
||||
hasModifiedSelection = true
|
||||
customPatchSelection = update(currentSelection())
|
||||
}
|
||||
|
||||
fun deselectAll(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
|
||||
updateSelection { selection ->
|
||||
bundles.fold(selection) { acc, bundle ->
|
||||
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
|
||||
acc.put(bundle.uid, persistentSetOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
|
||||
updateSelection { selection ->
|
||||
bundles.fold(selection) { acc, bundle ->
|
||||
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
|
||||
|
||||
val currentSelected = acc[bundle.uid] ?: persistentSetOf()
|
||||
val inverted = bundle.patchSequence(allowIncompatiblePatches)
|
||||
.filter { it.name !in currentSelected }
|
||||
.map { it.name }
|
||||
.toPersistentSet()
|
||||
acc.put(bundle.uid, inverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreDefaults(bundleUid: Int?) = viewModelScope.launch {
|
||||
if (bundleUid == null) {
|
||||
customPatchSelection = null
|
||||
hasModifiedSelection = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val defaults = defaultPatchSelection.first()
|
||||
updateSelection { selection ->
|
||||
selection.put(bundleUid, defaults[bundleUid] ?: persistentSetOf())
|
||||
}
|
||||
}
|
||||
|
||||
fun deselectAllExcept(bundles: List<PatchBundleInfo.Scoped>, keepBundleUid: Int) = viewModelScope.launch {
|
||||
updateSelection { selection ->
|
||||
bundles.fold(selection) { acc, bundle ->
|
||||
if (bundle.uid == keepBundleUid) return@fold acc
|
||||
acc.put(bundle.uid, persistentSetOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHOW_INCOMPATIBLE = 1 // 2^0
|
||||
const val SHOW_UNIVERSAL = 2 // 2^1
|
||||
|
||||
private val optionsSaver: Saver<PersistentOptions, Options> = snapshotStateMapSaver(
|
||||
// Patch name -> Options
|
||||
valueSaver = persistentMapSaver(
|
||||
// Option key -> Option value
|
||||
valueSaver = persistentMapSaver()
|
||||
)
|
||||
)
|
||||
|
||||
private val selectionSaver: Saver<PersistentPatchSelection?, Nullable<PatchSelection>> =
|
||||
nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver()))
|
||||
}
|
||||
|
||||
sealed interface DialogState {
|
||||
data class Options(val bundle: Int, val patch: PatchInfo) : DialogState
|
||||
data class IncompatiblePatch(val compatibleVersions: Set<String>) : DialogState
|
||||
data object IncompatiblePatchesInfo : DialogState
|
||||
data object SelectionWarning : DialogState
|
||||
data object UniversalPatchWarning : DialogState
|
||||
}
|
||||
|
||||
private fun mergeSourcesWithBundleInfo(
|
||||
sources: List<PatchBundleSource>,
|
||||
scopedBundleInfoByUid: Map<Int, PatchBundleInfo.Scoped>
|
||||
) = sources.map { source ->
|
||||
scopedBundleInfoByUid[source.uid] ?: source.emptyScopedBundleInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// Versions of other types, but utilizing persistent/observable collection types.
|
||||
private typealias PersistentOptions = SnapshotStateMap<Int, PersistentMap<String, PersistentMap<String, Any?>>>
|
||||
private typealias PersistentPatchSelection = PersistentMap<Int, PersistentSet<String>>
|
||||
|
||||
private fun PatchSelection.toPersistentPatchSelection(): PersistentPatchSelection =
|
||||
mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap()
|
||||
|
||||
private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped(
|
||||
name = name,
|
||||
version = version,
|
||||
releasedAt = releasedAt,
|
||||
uid = uid,
|
||||
patches = patches,
|
||||
compatible = patches,
|
||||
incompatible = emptyList(),
|
||||
universal = emptyList()
|
||||
)
|
||||
|
||||
private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped(
|
||||
name = name,
|
||||
version = version,
|
||||
releasedAt = (this.asRemoteOrNull)?.releasedAt,
|
||||
uid = uid,
|
||||
patches = emptyList(),
|
||||
compatible = emptyList(),
|
||||
incompatible = emptyList(),
|
||||
universal = emptyList()
|
||||
)
|
||||
|
||||
@@ -275,7 +275,13 @@ val ScrollState.isScrollingUp: Boolean @Composable get() = this.isScrollingUp().
|
||||
fun <R> (() -> R).withHapticFeedback(constant: Int): () -> R {
|
||||
val view = LocalView.current
|
||||
return {
|
||||
view.performHapticFeedback(constant)
|
||||
try {
|
||||
view.performHapticFeedback(constant)
|
||||
} catch (e: NullPointerException) {
|
||||
// Note, some devices using RichTap hardware reported crashing, what??
|
||||
// https://github.com/ReVanced/revanced-manager/issues/3224
|
||||
Log.e("RVM-Util/Vibration", "Vibration failed with NPE, what?", e)
|
||||
}
|
||||
this()
|
||||
}
|
||||
}
|
||||
@@ -285,7 +291,13 @@ fun <R> (() -> R).withHapticFeedback(constant: Int): () -> R {
|
||||
fun <T, R> ((T) -> R).withHapticFeedback(constant: Int): (T) -> R {
|
||||
val view = LocalView.current
|
||||
return {
|
||||
view.performHapticFeedback(constant)
|
||||
try {
|
||||
view.performHapticFeedback(constant)
|
||||
} catch (e: NullPointerException) {
|
||||
// Note, some devices using RichTap hardware reported crashing, what??
|
||||
// https://github.com/ReVanced/revanced-manager/issues/3224
|
||||
Log.e("RVM-Util/Vibration", "Vibration failed with NPE, what?", e)
|
||||
}
|
||||
this(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ Second \"item\" text"</string>
|
||||
<string name="patch_selector_item_description">%d patches selected</string>
|
||||
<string name="patch_selection_changed_warning">Patch selection has been changed</string>
|
||||
<string name="no_patches_selected">No patches selected</string>
|
||||
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
|
||||
|
||||
<string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string>
|
||||
|
||||
<string name="apk_source_selector_item">Select APK source</string>
|
||||
@@ -233,6 +233,8 @@ You won’t be able to update apps that were signed with the previous keystore.<
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="discard_changes">Discard changes</string>
|
||||
<string name="discard_changes_dialog_title">Discard changes?</string>
|
||||
<string name="dialog_input_placeholder">Value</string>
|
||||
<string name="reset">Reset</string>
|
||||
<string name="share">Share</string>
|
||||
@@ -309,6 +311,8 @@ You won’t be able to update apps that were signed with the previous keystore.<
|
||||
<string name="no_patched_apps_description">You don’t have any patched apps yet. Start by patching your first app!</string>
|
||||
<string name="patched_apps_section_title">Patched apps</string>
|
||||
<string name="patchable_apps_section_title">Apps that can be patched</string>
|
||||
<string name="pinned_apps_section_title">Pinned apps</string>
|
||||
<string name="available_apps_section_title">Available apps</string>
|
||||
<string name="no_patches_found">No patches found</string>
|
||||
<string name="no_patches_description">You don’t have any patches yet. Add patches by tapping the button below!</string>
|
||||
<string name="tap_on_patches">Tap on the patches to get more information about them</string>
|
||||
@@ -317,6 +321,22 @@ You won’t be able to update apps that were signed with the previous keystore.<
|
||||
<string name="universal_patches">Universal patches</string>
|
||||
<string name="patch_selection_reset_toast">Patch selection and options have been reset to recommended defaults</string>
|
||||
<string name="patch_options_reset_toast">Patch options have been reset</string>
|
||||
<string name="patch_options_discard_invalid_description">Invalid options will reset to the last saved value.</string>
|
||||
<string name="patch_options_set_default">Use default value</string>
|
||||
<string name="patch_options_value_null">Not specified</string>
|
||||
<string name="patch_options_value_invalid">Invalid value</string>
|
||||
<string name="patch_options_value_boolean_true">Enabled</string>
|
||||
<string name="patch_options_value_boolean_false">Disabled</string>
|
||||
<string name="patch_options_value_required">This option is required</string>
|
||||
<string name="patch_options_value_required_list_empty">This list is required, but you haven’t added any items.</string>
|
||||
<string name="patch_options_using_default_value">Using default value</string>
|
||||
<string name="patch_options_value_list_element">Value</string>
|
||||
<string name="patch_options_value_list_invalid_dialog_title">Can’t save option</string>
|
||||
<string name="patch_options_value_list_invalid_dialog_description">This list doesn’t follow the required format.</string>
|
||||
<string name="patch_options_value_list_element_count">%d values</string>
|
||||
<string name="patch_options_no_default_items">No default items for this option</string>
|
||||
<string name="patch_options_value_required_list_empty_save_warning_title">Save without items?</string>
|
||||
<string name="patch_options_value_required_list_empty_save_warning_description">This option is required, and usually needs at least one item for the patch to have an effect.</string>
|
||||
<string name="non_suggested_version_warning_title">Non suggested version</string>
|
||||
<string name="non_suggested_version_warning_description">The version of the app you selected doesn’t match the suggested version: %s
|
||||
|
||||
@@ -511,7 +531,6 @@ It’s only compatible with these versions: %2$s</string>
|
||||
<string name="days_ago">%sd ago</string>
|
||||
<string name="invalid_date">Invalid date</string>
|
||||
<string name="input_dialog_value_invalid">Invalid value</string>
|
||||
<string name="option_required">This option is required</string>
|
||||
<string name="required_options_screen">Required options</string>
|
||||
|
||||
<string name="failed_to_check_updates">Couldn’t check for updates: %s</string>
|
||||
@@ -566,7 +585,6 @@ Tap them for more details.</string>
|
||||
<string name="incompatible_patch">Incompatible patch</string>
|
||||
<string name="any_version">Any</string>
|
||||
<string name="never_show_again">Don’t show this again</string>
|
||||
<string name="open_downloaders">Open downloaders</string>
|
||||
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
|
||||
<string name="show_manager_update_dialog_on_launch_description">Get notified when you open the app and a new update is available</string>
|
||||
<string name="failed_to_import_keystore">Couldn’t import keystore</string>
|
||||
@@ -579,8 +597,11 @@ Tap them for more details.</string>
|
||||
|
||||
<string name="sideeffect_restart">Restart the app to see changes</string>
|
||||
<string name="sideeffect_no_network">You’re offline. Check your internet connection.</string>
|
||||
<string name="open_downloaders">Open downloaders</string>
|
||||
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
|
||||
<string name="source_selector_category_downloaded">Previously downloaded</string>
|
||||
|
||||
|
||||
<plurals name="patch_count">
|
||||
<item quantity="one">%d patch</item>
|
||||
<item quantity="other">%d patches</item>
|
||||
|
||||
Reference in New Issue
Block a user