diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index fd83a51ed1..17ef86523c 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -23,8 +23,7 @@ { "fieldPath": "version", "columnName": "version", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "source", @@ -44,9 +43,7 @@ "columnNames": [ "uid" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "patch_selections", @@ -127,7 +124,6 @@ "patch_name" ] }, - "indices": [], "foreignKeys": [ { "table": "patch_selections", @@ -177,9 +173,7 @@ "package_name", "version" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "installed_app", @@ -215,9 +209,7 @@ "columnNames": [ "current_package_name" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "applied_patch", @@ -378,7 +370,6 @@ "key" ] }, - "indices": [], "foreignKeys": [ { "table": "option_groups", @@ -415,12 +406,9 @@ "columnNames": [ "package_name" ] - }, - "indices": [], - "foreignKeys": [] + } } ], - "views": [], "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, 'd0119047505da435972c5247181de675')" diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/2.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/2.json new file mode 100644 index 0000000000..96091a6e91 --- /dev/null +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/2.json @@ -0,0 +1,448 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "799b1231ff07c511b2c9a27b4c28c19d", + "entities": [ + { + "tableName": "patch_bundles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `version` TEXT, `search_update` INTEGER NOT NULL, `auto_update` INTEGER NOT NULL, `changelog` TEXT, `publish_date` TEXT, `latest_changelog` TEXT, `latest_publish_date` TEXT, `latest_version` TEXT, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "properties.version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "properties.searchUpdate", + "columnName": "search_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "properties.autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteProperties.changelog", + "columnName": "changelog", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteProperties.publishDate", + "columnName": "publish_date", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteLatestProperties.latestChangelog", + "columnName": "latest_changelog", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteLatestProperties.latestPublishDate", + "columnName": "latest_publish_date", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteLatestProperties.latestVersion", + "columnName": "latest_version", + "affinity": "TEXT" + } + ], + "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 , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) 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" + ] + }, + "indices": [ + { + "name": "index_applied_patch_bundle", + "unique": false, + "columnNames": [ + "bundle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_applied_patch_bundle` ON `${TABLE_NAME}` (`bundle`)" + } + ], + "foreignKeys": [ + { + "table": "installed_app", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "package_name" + ], + "referencedColumns": [ + "current_package_name" + ] + }, + { + "table": "patch_bundles", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bundle" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "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": "key", + "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": "trusted_downloader_plugins", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` BLOB NOT NULL, PRIMARY KEY(`package_name`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name" + ] + } + } + ], + "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, '799b1231ff07c511b2c9a27b4c28c19d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0404d045d8..1709a7f73d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:description="@string/plugin_host_permission_description" /> + diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 1d17e5ef61..be4f316cdb 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -4,17 +4,26 @@ import android.app.Activity import android.app.Application import android.os.Bundle import android.util.Log +import androidx.work.Configuration import app.revanced.manager.data.platform.Filesystem -import app.revanced.manager.di.* +import app.revanced.manager.di.databaseModule +import app.revanced.manager.di.httpModule +import app.revanced.manager.di.managerModule +import app.revanced.manager.di.preferencesModule +import app.revanced.manager.di.repositoryModule +import app.revanced.manager.di.rootModule +import app.revanced.manager.di.serviceModule +import app.revanced.manager.di.viewModelModule +import app.revanced.manager.di.workerModule import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloaderPluginRepository import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.util.tag -import kotlinx.coroutines.Dispatchers import coil.Coil import coil.ImageLoader import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.internal.BuilderImpl +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import me.zhanghai.android.appiconloader.coil.AppIconFetcher @@ -25,13 +34,17 @@ import org.koin.android.ext.koin.androidLogger import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.core.context.startKoin -class ManagerApplication : Application() { +class ManagerApplication : Application(), Configuration.Provider { private val scope = MainScope() private val prefs: PreferencesManager by inject() private val patchBundleRepository: PatchBundleRepository by inject() private val downloaderPluginRepository: DownloaderPluginRepository by inject() private val fs: Filesystem by inject() + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .build() + override fun onCreate() { super.onCreate() @@ -51,7 +64,6 @@ class ManagerApplication : Application() { rootModule ) } - val pixels = 512 Coil.setImageLoader( ImageLoader.Builder(this) diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 403bd1cf71..8bff26a528 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -22,7 +22,7 @@ import kotlin.random.Random @Database( entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class, TrustedDownloaderPlugin::class], - version = 1 + version = 2 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt index d9955a702b..ee01d9f273 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt @@ -2,21 +2,37 @@ package app.revanced.manager.data.room.bundles import androidx.room.* import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.LocalDateTime @Dao interface PatchBundleDao { @Query("SELECT * FROM patch_bundles") suspend fun all(): List - @Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid") + @Query("SELECT version, search_update, auto_update FROM patch_bundles WHERE uid = :uid") fun getPropsById(uid: Int): Flow + @Query("SELECT latest_version, latest_changelog, latest_publish_date FROM patch_bundles WHERE uid = :uid") + fun getLatestPropsById(uid: Int): Flow + + @Query("SELECT changelog, publish_date FROM patch_bundles WHERE uid = :uid") + fun getInstalledProps(uid: Int): Flow + @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid") suspend fun updateVersion(uid: Int, patches: String?) + @Query("UPDATE patch_bundles SET changelog = :changelog, publish_date = :createdAt WHERE uid = :uid") + suspend fun updateInstallationProps(uid: Int, changelog: String, createdAt: String) + + @Query("UPDATE patch_bundles SET latest_version = :version, latest_changelog = :changelog, latest_publish_date = :createdAt WHERE uid = :uid") + suspend fun updateLatestRemoteInfo(uid: Int, version: String, changelog: String, createdAt: String) + @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid") suspend fun setAutoUpdate(uid: Int, value: Boolean) + @Query("UPDATE patch_bundles SET search_update = :value WHERE uid = :uid") + suspend fun setSearchUpdate(uid: Int, value: Boolean) + @Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid") suspend fun setName(uid: Int, value: String) diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt index 8ba5f64a96..4664aac56d 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt @@ -33,12 +33,25 @@ sealed class Source { data class PatchBundleEntity( @PrimaryKey val uid: Int, @ColumnInfo(name = "name") val name: String, - @ColumnInfo(name = "version") val version: String? = null, @ColumnInfo(name = "source") val source: Source, - @ColumnInfo(name = "auto_update") val autoUpdate: Boolean + @Embedded val properties: BundleProperties, + @Embedded val remoteProperties: RemoteBundleProperties? = null, + @Embedded val remoteLatestProperties: RemoteLatestBundleProperties? = null ) data class BundleProperties( @ColumnInfo(name = "version") val version: String? = null, + @ColumnInfo(name = "search_update") val searchUpdate: Boolean, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean +) + +data class RemoteBundleProperties( + @ColumnInfo(name = "changelog") val changelog: String? = null, + @ColumnInfo(name = "publish_date") val publishDate: String? = null +) + +data class RemoteLatestBundleProperties( + @ColumnInfo(name = "latest_version") val latestVersion: String? = null, + @ColumnInfo(name = "latest_changelog") val latestChangelog: String? = null, + @ColumnInfo(name = "latest_publish_date") val latestPublishDate: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt b/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt index 37d8c05dd6..465630b714 100644 --- a/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt +++ b/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt @@ -2,12 +2,33 @@ package app.revanced.manager.di import android.content.Context import androidx.room.Room +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import app.revanced.manager.data.room.AppDatabase import org.koin.android.ext.koin.androidContext import org.koin.dsl.module + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add columns for RemoteBundleProperties + database.execSQL("ALTER TABLE patch_bundles ADD COLUMN search_update INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE patch_bundles ADD COLUMN changelog TEXT") + database.execSQL("ALTER TABLE patch_bundles ADD COLUMN publish_date TEXT") + + // Add columns for RemoteLatestBundleProperties + database.execSQL("ALTER TABLE patch_bundles ADD COLUMN latest_changelog TEXT") + database.execSQL("ALTER TABLE patch_bundles ADD COLUMN latest_publish_date TEXT") + database.execSQL("ALTER TABLE patch_bundles ADD COLUMN latest_version TEXT") + } +} + val databaseModule = module { - fun provideAppDatabase(context: Context) = Room.databaseBuilder(context, AppDatabase::class.java, "manager").build() + fun provideAppDatabase(context: Context) = + Room + .databaseBuilder(context, AppDatabase::class.java, "manager") + .addMigrations(MIGRATION_1_2) + .build() single { provideAppDatabase(androidContext()) diff --git a/app/src/main/java/app/revanced/manager/di/WorkerModule.kt b/app/src/main/java/app/revanced/manager/di/WorkerModule.kt index d5d9112e9b..56963d5c3d 100644 --- a/app/src/main/java/app/revanced/manager/di/WorkerModule.kt +++ b/app/src/main/java/app/revanced/manager/di/WorkerModule.kt @@ -1,9 +1,11 @@ package app.revanced.manager.di +import app.revanced.manager.patcher.worker.BundleUpdateNotificationWorker import app.revanced.manager.patcher.worker.PatcherWorker import org.koin.androidx.workmanager.dsl.workerOf import org.koin.dsl.module val workerModule = module { workerOf(::PatcherWorker) + workerOf(::BundleUpdateNotificationWorker) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt index bcbc59cf83..a150eb75fe 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt @@ -15,7 +15,7 @@ class LocalPatchBundle(name: String, id: Int, directory: File) : } reload()?.also { - saveVersion(it.readManifestAttribute("Version")) + updateVersion(it.readManifestAttribute("Version")) } } } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt index 308e2a56dd..c32aa6f920 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt @@ -84,10 +84,18 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default) suspend fun getProps() = propsFlow().first()!! - suspend fun currentVersion() = getProps().version - protected suspend fun saveVersion(version: String?) = + fun installedPropsFlow() = configRepository.getInstalledProps(uid).flowOn(Dispatchers.Default) + suspend fun getInstalledProps() = installedPropsFlow().first()!! + + fun latestPropsFlow() = configRepository.getLatestProps(uid).flowOn(Dispatchers.Default) + suspend fun getLatestProps() = latestPropsFlow().first()!! + + protected suspend fun updateVersion(version: String?) = configRepository.updateVersion(uid, version) + protected suspend fun updateInstallationProps(changelog: String, createdAt: String) = + configRepository.updateInstallationProps(uid, changelog, createdAt) + suspend fun setName(name: String) { configRepository.setName(uid, name) _nameFlow.value = name diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index 9deb7bbe22..5986a87fb9 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -10,12 +10,33 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.core.component.inject import java.io.File +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + + +class RemotePatchBundleFetchResponse( + val response: ReVancedAsset, + oldVersion: String?, + oldLatestVersion: String? +) { + val isNewLatestVersion = response.version != oldLatestVersion + + val isLatestInstalled = response.version == oldVersion +} @Stable sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) : PatchBundleSource(name, id, directory) { protected val http: HttpService by inject() + fun canUpdateVersionFlow(): Flow = + combine(propsFlow(), latestPropsFlow()) { current, latest -> + current?.version != latest?.latestVersion + } + + suspend fun canUpdateVersion() = canUpdateVersionFlow().first() + protected abstract suspend fun getLatestInfo(): ReVancedAsset private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { @@ -25,7 +46,8 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo } } - saveVersion(info.version) + updateVersion(info.version) + updateInstallationProps(info.description, info.createdAt.toString()) reload() } @@ -33,12 +55,25 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo download(getLatestInfo()) } + suspend fun fetchLatestRemoteInfo(): RemotePatchBundleFetchResponse = withContext(Dispatchers.Default) { + getLatestInfo().let { + val result = RemotePatchBundleFetchResponse(it, getProps().version, getLatestProps().latestVersion) + configRepository.updateLatestRemoteInfo( + uid, + it.version, + it.description, + it.createdAt.toString() + ) + result + } + } + suspend fun update(): Boolean = withContext(Dispatchers.IO) { - val info = getLatestInfo() - if (hasInstalled() && info.version == currentVersion()) + val fetchedInfo = fetchLatestRemoteInfo() + if (!canUpdateVersion()) return@withContext false - download(info) + download(fetchedInfo.response) true } @@ -49,6 +84,8 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value) + suspend fun setSearchUpdate(value: Boolean) = configRepository.setSearchUpdate(uid, value) + companion object { const val updateFailMsg = "Failed to update patch bundle(s)" } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 90f45a1c87..d94cbacceb 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -1,10 +1,19 @@ package app.revanced.manager.domain.manager import android.content.Context +import app.revanced.manager.R import app.revanced.manager.domain.manager.base.BasePreferencesManager import app.revanced.manager.ui.theme.Theme import app.revanced.manager.util.isDebuggable + +enum class SearchForUpdatesBackgroundInterval(val displayName: Int, val value: Long) { + NEVER(R.string.never, 0), + MIN15(R.string.minutes_15, 15), + HOUR(R.string.hourly, 60), + DAY(R.string.daily, 60 * 24) +} + class PreferencesManager( context: Context ) : BasePreferencesManager(context, "settings") { @@ -22,6 +31,7 @@ class PreferencesManager( val firstLaunch = booleanPreference("first_launch", true) val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true) + val searchForUpdatesBackgroundInterval = enumPreference("background_bundle_update_time", SearchForUpdatesBackgroundInterval.NEVER) val disablePatchVersionCompatCheck = booleanPreference("disable_patch_version_compatibility_check", false) val disableSelectionWarning = booleanPreference("disable_selection_warning", false) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt index 5711d99758..faa1a40b61 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt @@ -2,9 +2,11 @@ package app.revanced.manager.domain.repository import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.AppDatabase.Companion.generateUid +import app.revanced.manager.data.room.bundles.BundleProperties import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.Source import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.datetime.LocalDateTime class PatchBundlePersistenceRepository(db: AppDatabase) { private val dao = db.patchBundleDao() @@ -21,13 +23,16 @@ class PatchBundlePersistenceRepository(db: AppDatabase) { suspend fun reset() = dao.reset() - suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) = + suspend fun create(name: String, source: Source, searchUpdate: Boolean = false, autoUpdate: Boolean = false) = PatchBundleEntity( uid = generateUid(), name = name, - version = null, source = source, - autoUpdate = autoUpdate + properties = BundleProperties( + version = null, + searchUpdate = searchUpdate, + autoUpdate = autoUpdate + ) ).also { dao.add(it) } @@ -37,19 +42,34 @@ class PatchBundlePersistenceRepository(db: AppDatabase) { suspend fun updateVersion(uid: Int, version: String?) = dao.updateVersion(uid, version) + suspend fun updateInstallationProps(uid: Int, changelog: String, createdAt: String) = + dao.updateInstallationProps(uid, changelog, createdAt) + + suspend fun updateLatestRemoteInfo(uid: Int, version: String, changelog: String, createdAt: String) = + dao.updateLatestRemoteInfo(uid, version, changelog, createdAt) + suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value) + suspend fun setSearchUpdate(uid: Int, value: Boolean) = dao.setSearchUpdate(uid, value) + suspend fun setName(uid: Int, name: String) = dao.setName(uid, name) fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged() + fun getLatestProps(id: Int) = dao.getLatestPropsById(id).distinctUntilChanged() + + fun getInstalledProps(id: Int) = dao.getInstalledProps(id).distinctUntilChanged() + private companion object { val defaultSource = PatchBundleEntity( uid = 0, name = "", - version = null, - source = Source.API, - autoUpdate = false + properties = BundleProperties( + version = null, + searchUpdate = false, + autoUpdate = false + ), + source = Source.API ) } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt index 79bb5cea64..7b73d72278 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt @@ -1,6 +1,8 @@ package app.revanced.manager.domain.repository import android.app.Application +import android.app.Notification +import android.app.NotificationManager import android.content.Context import android.util.Log import app.revanced.library.mostCommonCompatibleVersions @@ -9,13 +11,13 @@ import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.domain.bundles.APIPatchBundle import app.revanced.manager.domain.bundles.JsonPatchBundle -import app.revanced.manager.data.room.bundles.Source as SourceInfo import app.revanced.manager.domain.bundles.LocalPatchBundle -import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.util.flatMapLatestAndCombine +import app.revanced.manager.util.permission.hasNotificationPermission import app.revanced.manager.util.tag import app.revanced.manager.util.uiSafe import kotlinx.coroutines.Dispatchers @@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.InputStream +import app.revanced.manager.data.room.bundles.Source as SourceInfo class PatchBundleRepository( private val app: Application, @@ -145,8 +148,8 @@ class PatchBundleRepository( addBundle(bundle) } - suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) { - val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate) + suspend fun createRemote(url: String, searchUpdate: Boolean, autoUpdate: Boolean) = withContext(Dispatchers.Default) { + val entity = persistenceRepo.create("", SourceInfo.from(url), searchUpdate, autoUpdate) addBundle(entity.load()) } @@ -181,4 +184,23 @@ class PatchBundleRepository( } } } + + suspend fun fetchUpdatesAndNotify(context: Context, notificationBlock: (bundleName: String, bundleVersion: String) -> Pair) { + coroutineScope { + getBundlesByType().forEach { bundle -> + Log.d(tag, "Running fetchUpdatesAndNotify for bundle: ${bundle.getName()}") + if (!bundle.getProps().searchUpdate || !context.hasNotificationPermission()) + return@forEach + + var fetchResponse = bundle.fetchLatestRemoteInfo() + if ( + !fetchResponse.isNewLatestVersion|| // Already notified + fetchResponse.isLatestInstalled // Latest is already installed + ) + return@forEach + + notificationBlock(bundle.getName(), fetchResponse.response.version) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt b/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt index 222a31c48b..6732ad1997 100644 --- a/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt @@ -1,11 +1,25 @@ package app.revanced.manager.domain.worker import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.util.Log +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import app.revanced.manager.R +import app.revanced.manager.domain.manager.SearchForUpdatesBackgroundInterval +import app.revanced.manager.patcher.worker.BundleUpdateNotificationWorker import java.util.UUID +import java.util.concurrent.TimeUnit class WorkerRepository(app: Application) { val workManager = WorkManager.getInstance(app) @@ -33,4 +47,52 @@ class WorkerRepository(app: Application) { workManager.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request) return request.id } + + inline fun createNotification( + context: Context, + notificationChannel: NotificationChannel, + title: String, + description: String + ): Pair { + val notificationIntent = Intent(context, T::class.java) + val pendingIntent: PendingIntent = PendingIntent.getActivity( + context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE + ) + + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(notificationChannel) + + return Pair( + Notification.Builder(context, notificationChannel.id) + .setContentTitle(title) + .setContentText(description) + .setLargeIcon(Icon.createWithResource(context, R.drawable.ic_notification)) + .setSmallIcon(Icon.createWithResource(context, R.drawable.ic_notification)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build(), + notificationManager + ) + } + + fun scheduleBundleUpdateNotificationWork(bundleUpdateTime: SearchForUpdatesBackgroundInterval) { + val workId = "BundleUpdateNotificationWork" + if(bundleUpdateTime == SearchForUpdatesBackgroundInterval.NEVER) { + workManager.cancelUniqueWork(workId) + Log.d("WorkManager","Cancelled job with workId $workId.") + } else { + val workRequest = + PeriodicWorkRequestBuilder(bundleUpdateTime.value, TimeUnit.MINUTES) + .build() + + workManager + .enqueueUniquePeriodicWork( + workId, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + workRequest + ) + Log.d("WorkManager", "Periodic work $workId updated with time ${bundleUpdateTime.value}.") + } + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/BundleUpdateNotificationWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/BundleUpdateNotificationWorker.kt new file mode 100644 index 0000000000..b2074187d6 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/patcher/worker/BundleUpdateNotificationWorker.kt @@ -0,0 +1,69 @@ +package app.revanced.manager.patcher.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.util.Log +import androidx.work.WorkerParameters +import app.revanced.manager.MainActivity +import app.revanced.manager.R +import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.domain.worker.Worker +import app.revanced.manager.domain.worker.WorkerRepository +import app.revanced.manager.plugin.downloader.PluginHostApi +import app.revanced.manager.util.permission.hasNotificationPermission +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +@OptIn(PluginHostApi::class) +class BundleUpdateNotificationWorker( + context: Context, + parameters: WorkerParameters +) : Worker(context, parameters), KoinComponent { + private val patchBundleRepository: PatchBundleRepository by inject() + private val workerRepository: WorkerRepository by inject() + + class Args() + + val notificationChannel = NotificationChannel( + "background-bundle-update-channel", "Background Check Notifications", NotificationManager.IMPORTANCE_HIGH + ) + companion object { + const val LOG_TAG = "BundleAutoUpdateWorker" + } + + override suspend fun doWork(): Result { + /** + * If the user did not consent to be notified, there is no point in checking in background. + * The auto update will still happen on app opening. + **/ + if (!applicationContext.hasNotificationPermission()) + return Result.success() + + Log.d(LOG_TAG, "Searching for updates.") + return try { + patchBundleRepository.fetchUpdatesAndNotify(applicationContext) { bundleName, bundleVersion -> + workerRepository.createNotification( + applicationContext, + notificationChannel, + applicationContext.getString(R.string.bundle_update_available), + applicationContext.getString( + R.string.bundle_update_description_available, + bundleName, + bundleVersion + ) + ).also { (notification, notificationManager) -> + if (applicationContext.hasNotificationPermission()) + notificationManager.notify( + "$bundleName-$bundleVersion".hashCode(), + notification + ) + } + } + Result.success() + } catch (e: Exception) { + Log.d(LOG_TAG, "Error during work: ${e.message}") + Result.failure() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index 5096170caa..af8642e06b 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -1,20 +1,16 @@ package app.revanced.manager.patcher.worker import android.app.Activity -import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo -import android.graphics.drawable.Icon import android.os.Build import android.os.Parcelable import android.os.PowerManager import android.util.Log import androidx.activity.result.ActivityResult -import androidx.core.content.ContextCompat import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import app.revanced.manager.R @@ -79,32 +75,23 @@ class PatcherWorker( ) { val packageName get() = input.packageName } + val notificationChannel = NotificationChannel( + "revanced-patcher-patching", "Patching", NotificationManager.IMPORTANCE_HIGH + ) override suspend fun getForegroundInfo() = - ForegroundInfo( - 1, - createNotification(), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 - ) - - private fun createNotification(): Notification { - val notificationIntent = Intent(applicationContext, PatcherWorker::class.java) - val pendingIntent: PendingIntent = PendingIntent.getActivity( - applicationContext, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE - ) - val channel = NotificationChannel( - "revanced-patcher-patching", "Patching", NotificationManager.IMPORTANCE_HIGH - ) - val notificationManager = - ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) - notificationManager!!.createNotificationChannel(channel) - return Notification.Builder(applicationContext, channel.id) - .setContentTitle(applicationContext.getText(R.string.app_name)) - .setContentText(applicationContext.getText(R.string.patcher_notification_message)) - .setLargeIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification)) - .setSmallIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification)) - .setContentIntent(pendingIntent).build() - } + workerRepository.createNotification( + applicationContext, + notificationChannel, + applicationContext.getString(R.string.app_name), + applicationContext.getString(R.string.patcher_notification_message) + ).first.let { + ForegroundInfo( + 1, + it, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 + ) + } override suspend fun doWork(): Result { if (runAttemptCount > 0) { diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt index dfc63735b9..cf95d4a4ff 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt @@ -2,16 +2,26 @@ package app.revanced.manager.ui.component.bundle import android.webkit.URLUtil import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.outlined.Extension import androidx.compose.material.icons.outlined.Inventory2 import androidx.compose.material.icons.outlined.Sell -import androidx.compose.material3.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -20,10 +30,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R +import app.revanced.manager.domain.manager.PreferencesManager +import app.revanced.manager.domain.manager.SearchForUpdatesBackgroundInterval import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.TextInputDialog import app.revanced.manager.ui.component.haptics.HapticSwitch +import org.koin.compose.koinInject @Composable fun BaseBundleDialog( @@ -36,9 +50,16 @@ fun BaseBundleDialog( version: String?, autoUpdate: Boolean, onAutoUpdateChange: (Boolean) -> Unit, + searchUpdate: Boolean, + onSearchUpdateChange: (Boolean) -> Unit, onPatchesClick: () -> Unit, - extraFields: @Composable ColumnScope.() -> Unit = {} + extraFields: @Composable ColumnScope.() -> Unit = {}, + prefs: PreferencesManager = koinInject() ) { + val searchUpdatesScheduledJobInterval by remember { + prefs.searchForUpdatesBackgroundInterval.flow + }.collectAsStateWithLifecycle(null) + ColumnWithScrollbar( modifier = Modifier .fillMaxWidth() @@ -85,7 +106,33 @@ fun BaseBundleDialog( color = MaterialTheme.colorScheme.outlineVariant ) - if (remoteUrl != null) { + if ( + remoteUrl != null && + searchUpdatesScheduledJobInterval != null + ) { + BundleListItem( + headlineText = stringResource(R.string.bundle_search_update), + supportingText = stringResource(R.string.bundle_search_update_description), + //TODO imrpove description if it's off + trailingContent = { + if (searchUpdatesScheduledJobInterval != SearchForUpdatesBackgroundInterval.NEVER) { + HapticSwitch( + checked = searchUpdate, + onCheckedChange = onSearchUpdateChange + ) + } else { + HapticSwitch( + checked = false, + onCheckedChange = onSearchUpdateChange, + enabled = false + ) + } + }, + modifier = Modifier.clickable { + onSearchUpdateChange(!searchUpdate) + } + ) + BundleListItem( headlineText = stringResource(R.string.bundle_auto_update), supportingText = stringResource(R.string.bundle_auto_update_description), diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index 400bdd40e8..7ee3be24b4 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -1,17 +1,26 @@ package app.revanced.manager.ui.component.bundle import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.InstallMobile +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.data.platform.NetworkInfo @@ -20,9 +29,17 @@ import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState +import app.revanced.manager.domain.bundles.RemotePatchBundle +import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ExceptionViewerDialog import app.revanced.manager.ui.component.FullscreenDialog +import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton +import app.revanced.manager.ui.component.settings.Changelog +import app.revanced.manager.util.relativeTime +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDateTime import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @@ -31,29 +48,33 @@ fun BundleInformationDialog( onDismissRequest: () -> Unit, onDeleteRequest: () -> Unit, bundle: PatchBundleSource, + onSearchUpdate: () -> Unit, onUpdate: () -> Unit, + fromUpdateClick: Boolean ) { val networkInfo = koinInject() val hasNetwork = remember { networkInfo.isConnected() } val composableScope = rememberCoroutineScope() var viewCurrentBundlePatches by remember { mutableStateOf(false) } + var viewChangelogDialog by remember { mutableStateOf(false) } + var updateBundleDialog by remember { mutableStateOf(fromUpdateClick) } val isLocal = bundle is LocalPatchBundle val state by bundle.state.collectAsStateWithLifecycle() val props by remember(bundle) { bundle.propsFlow() }.collectAsStateWithLifecycle(null) + val installedProps by remember(bundle) { + bundle.installedPropsFlow() + }.collectAsStateWithLifecycle(null) + val latestProps by remember(bundle) { + bundle.latestPropsFlow() + }.collectAsStateWithLifecycle(null) val patchCount = remember(state) { state.patchBundleOrNull()?.patches?.size ?: 0 } - - if (viewCurrentBundlePatches) { - BundlePatchesDialog( - onDismissRequest = { - viewCurrentBundlePatches = false - }, - bundle = bundle, - ) - } + val canUpdateState by remember(bundle) { + if (bundle is RemotePatchBundle) bundle.canUpdateVersionFlow() else flowOf(false) + }.collectAsStateWithLifecycle(null) FullscreenDialog( onDismissRequest = onDismissRequest, @@ -80,14 +101,36 @@ fun BundleInformationDialog( ) } } - if (!isLocal && hasNetwork) { - IconButton(onClick = onUpdate) { + + if (!isLocal) { + IconButton(onClick = onSearchUpdate) { Icon( - Icons.Outlined.Update, + Icons.Outlined.Refresh, stringResource(R.string.refresh) ) } } + + val canUpdate = bundle is RemotePatchBundle && canUpdateState == true + + IconButton( + onClick = { + if (canUpdateState == true) { + updateBundleDialog = true + } + }, + enabled = canUpdateState == true + ) { + if (canUpdateState == true) { + BadgedBox(badge = { + Badge(modifier = Modifier.size(6.dp)) + }) { + Icon(Icons.Outlined.Update, stringResource(R.string.update)) + } + } else { + Icon(Icons.Outlined.Update, stringResource(R.string.update)) + } + } } ) }, @@ -105,6 +148,12 @@ fun BundleInformationDialog( bundle.asRemoteOrNull?.setAutoUpdate(it) } }, + searchUpdate = props?.searchUpdate ?: false, + onSearchUpdateChange = { + composableScope.launch { + bundle.asRemoteOrNull?.setSearchUpdate(it) + } + }, onPatchesClick = { viewCurrentBundlePatches = true }, @@ -131,6 +180,20 @@ fun BundleInformationDialog( ) } + if (!isLocal) { + BundleListItem( + headlineText = stringResource(R.string.changelog), + supportingText = stringResource(R.string.changelog_description), + trailingContent = { + Icon( + Icons.AutoMirrored.Outlined.ArrowRight, + null + ) + }, + modifier = Modifier.clickable { viewChangelogDialog = true } + ) + } + if (state is PatchBundleSource.State.Missing && !isLocal) { BundleListItem( headlineText = stringResource(R.string.bundle_error), @@ -142,4 +205,105 @@ fun BundleInformationDialog( ) } } + + if (viewCurrentBundlePatches) { + BundlePatchesDialog( + onDismissRequest = { + viewCurrentBundlePatches = false + }, + bundle = bundle, + ) + } + + if (viewChangelogDialog) { + val publishDate = installedProps?.publishDate + val changelog = installedProps?.changelog + val version = props?.version + FullscreenDialog( + onDismissRequest = { viewChangelogDialog = false }, + ) { + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.changelog), + onBackClick = { viewChangelogDialog = false } + ) + } + ) { paddingValues -> + ColumnWithScrollbar( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (publishDate != null && version != null && changelog != null) { + Column(modifier = Modifier.padding(16.dp)) { + Changelog( + markdown = changelog.replace("`", ""), + version = version, + publishDate = LocalDateTime.parse(publishDate).relativeTime(LocalContext.current) + ) + } + } + } + } + } + } + + if (updateBundleDialog) { + val publishDate = latestProps?.latestPublishDate + val changelog = latestProps?.latestChangelog + val version = latestProps?.latestVersion + + FullscreenDialog( + onDismissRequest = { updateBundleDialog = false }, + ) { + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.update_available), + onBackClick = { updateBundleDialog = false } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + // Scrollable content + ColumnWithScrollbar( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 80.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (publishDate != null && version != null && changelog != null) { + Column(modifier = Modifier.padding(16.dp)) { + Changelog( + markdown = changelog.replace("`", ""), + version = version, + publishDate = LocalDateTime + .parse(publishDate) + .relativeTime(LocalContext.current) + ) + } + } + } + if (hasNetwork) + HapticExtendedFloatingActionButton( + onClick = { + onUpdate() + updateBundleDialog = false + }, + icon = { Icon(Icons.Outlined.InstallMobile, null) }, + text = { Text(stringResource(R.string.download)) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt index d1644df45f..1fa3ef2a1d 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt @@ -3,24 +3,32 @@ package app.revanced.manager.ui.component.bundle import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NewReleases import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme 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.clip import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -28,8 +36,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState +import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.haptics.HapticCheckbox +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @OptIn(ExperimentalFoundationApi::class) @@ -37,6 +47,7 @@ import kotlinx.coroutines.flow.map fun BundleItem( bundle: PatchBundleSource, onDelete: () -> Unit, + onSearchUpdate: () -> Unit, onUpdate: () -> Unit, selectable: Boolean, onSelect: () -> Unit, @@ -44,12 +55,18 @@ fun BundleItem( toggleSelection: (Boolean) -> Unit, ) { var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) } + var fromChangelogClick by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } val state by bundle.state.collectAsStateWithLifecycle() val version by remember(bundle) { bundle.propsFlow().map { props -> props?.version } }.collectAsStateWithLifecycle(null) + + val canUpdateState by remember(bundle) { + if (bundle is RemotePatchBundle) bundle.canUpdateVersionFlow() else flowOf(false) + }.collectAsStateWithLifecycle(null) + val name by bundle.nameState if (viewBundleDialogPage) { @@ -58,6 +75,8 @@ fun BundleItem( onDeleteRequest = { showDeleteConfirmationDialog = true }, bundle = bundle, onUpdate = onUpdate, + onSearchUpdate = onSearchUpdate, + fromUpdateClick = fromChangelogClick ) } @@ -79,7 +98,10 @@ fun BundleItem( .height(64.dp) .fillMaxWidth() .combinedClickable( - onClick = { viewBundleDialogPage = true }, + onClick = { + viewBundleDialogPage = true + fromChangelogClick = false + }, onLongClick = onSelect, ), leadingContent = if (selectable) { @@ -98,7 +120,9 @@ fun BundleItem( } }, trailingContent = { - Row { + Row ( + verticalAlignment = Alignment.CenterVertically + ) { val icon = remember(state) { when (state) { is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.bundle_error @@ -116,6 +140,26 @@ fun BundleItem( ) } + if (bundle is RemotePatchBundle && canUpdateState == true) { + IconButton( + onClick = { + fromChangelogClick = true + viewBundleDialogPage = true + }, + modifier = Modifier + .clip(CircleShape) + .fillMaxHeight() + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.NewReleases, + contentDescription = "Update available", + modifier = Modifier + .size(24.dp), + ) + } + } + version?.let { Text(text = it) } } }, diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt index 37d9ed1ad0..ed38c15489 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt @@ -30,7 +30,7 @@ import app.revanced.manager.util.transparentListItemColors @Composable fun ImportPatchBundleDialog( onDismiss: () -> Unit, - onRemoteSubmit: (String, Boolean) -> Unit, + onRemoteSubmit: (String, Boolean, Boolean) -> Unit, onLocalSubmit: (Uri) -> Unit ) { var currentStep by rememberSaveable { mutableIntStateOf(0) } @@ -38,6 +38,7 @@ fun ImportPatchBundleDialog( var patchBundle by rememberSaveable { mutableStateOf(null) } var remoteUrl by rememberSaveable { mutableStateOf("") } var autoUpdate by rememberSaveable { mutableStateOf(false) } + var searchUpdate by rememberSaveable { mutableStateOf(false) } val patchActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -60,9 +61,11 @@ fun ImportPatchBundleDialog( patchBundle, remoteUrl, autoUpdate, + searchUpdate, { launchPatchActivity() }, { remoteUrl = it }, - { autoUpdate = it } + { autoUpdate = it }, + { searchUpdate = it } ) } ) @@ -89,7 +92,7 @@ fun ImportPatchBundleDialog( onClick = { when (bundleType) { BundleType.Local -> patchBundle?.let(onLocalSubmit) - BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate) + BundleType.Remote -> onRemoteSubmit(remoteUrl, searchUpdate, autoUpdate) } } ) { @@ -173,9 +176,11 @@ fun ImportBundleStep( patchBundle: Uri?, remoteUrl: String, autoUpdate: Boolean, + searchUpdate: Boolean, launchPatchActivity: () -> Unit, onRemoteUrlChange: (String) -> Unit, - onAutoUpdateChange: (Boolean) -> Unit + onAutoUpdateChange: (Boolean) -> Unit, + onSearchUpdateChange: (Boolean) -> Unit, ) { Column { when (bundleType) { @@ -230,6 +235,24 @@ fun ImportBundleStep( }, colors = transparentListItemColors ) + ListItem( + modifier = Modifier.clickable( + role = Role.Checkbox, + onClick = { onSearchUpdateChange(!searchUpdate) } + ), + headlineContent = { Text(stringResource(R.string.auto_update)) }, + leadingContent = { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + HapticCheckbox( + checked = searchUpdate, + onCheckedChange = { + onSearchUpdateChange(!searchUpdate) + } + ) + } + }, + colors = transparentListItemColors + ) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt index 0cd3b73935..298e589f08 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt @@ -79,7 +79,7 @@ data class BundleInfo( targetList.add(it) } - BundleInfo(source.getName(), source.currentVersion(), source.uid, compatible, incompatible, universal) + BundleInfo(source.getName(), source.getProps().version, source.uid, compatible, incompatible, universal) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt index 8704a06428..7dafee41bc 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt @@ -9,12 +9,14 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.bundle.BundleItem @Composable fun BundleListScreen( onDelete: (PatchBundleSource) -> Unit, + onSearchUpdate: (PatchBundleSource) -> Unit, onUpdate: (PatchBundleSource) -> Unit, sources: List, selectedSources: SnapshotStateList, @@ -40,6 +42,9 @@ fun BundleListScreen( onDelete = { onDelete(source) }, + onSearchUpdate = { + onSearchUpdate(source) + }, onUpdate = { onUpdate(source) }, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index 0d14cf9ec3..9ec7b9e283 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -58,7 +58,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.patcher.aapt.Aapt -import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AutoUpdatesDialog import app.revanced.manager.ui.component.AvailableUpdateDialog @@ -69,6 +68,7 @@ import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.viewmodel.DashboardViewModel +import app.revanced.manager.util.permission.PermissionRequestHandler import app.revanced.manager.util.RequestInstallAppsContract import app.revanced.manager.util.toast import kotlinx.coroutines.launch @@ -120,9 +120,9 @@ fun DashboardScreen( showAddBundleDialog = false vm.createLocalSource(patches) }, - onRemoteSubmit = { url, autoUpdate -> + onRemoteSubmit = { url, searchUpdate, autoUpdate -> showAddBundleDialog = false - vm.createRemoteSource(url, autoUpdate) + vm.createRemoteSource(url, searchUpdate, autoUpdate) } ) } @@ -142,19 +142,19 @@ fun DashboardScreen( } var showAndroid11Dialog by rememberSaveable { mutableStateOf(false) } - val installAppsPermissionLauncher = - rememberLauncherForActivityResult(RequestInstallAppsContract) { granted -> - showAndroid11Dialog = false - if (granted) onAppSelectorClick() - } - if (showAndroid11Dialog) Android11Dialog( - onDismissRequest = { - showAndroid11Dialog = false - }, - onContinue = { - installAppsPermissionLauncher.launch(androidContext.packageName) - } - ) + + if(showAndroid11Dialog) + PermissionRequestHandler( + contract = RequestInstallAppsContract, + input = androidContext.packageName, + title = stringResource(R.string.android_11_bug_dialog_title), + description = stringResource(R.string.android_11_bug_dialog_description), + icon = Icons.Outlined.BugReport, + onDismissRequest = { showAndroid11Dialog = false }, + onResult = { granted -> + if (granted) onAppSelectorClick() + } + ) var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } if (showDeleteConfirmationDialog) { @@ -355,6 +355,9 @@ fun DashboardScreen( onDelete = { vm.delete(it) }, + onSearchUpdate = { + vm.searchUpdate(it) + }, onUpdate = { vm.update(it) }, @@ -386,25 +389,4 @@ fun Notifications( } } } -} - -@Composable -fun Android11Dialog(onDismissRequest: () -> Unit, onContinue: () -> Unit) { - AlertDialogExtended( - onDismissRequest = onDismissRequest, - confirmButton = { - TextButton(onClick = onContinue) { - Text(stringResource(R.string.continue_)) - } - }, - title = { - Text(stringResource(R.string.android_11_bug_dialog_title)) - }, - icon = { - Icon(Icons.Outlined.BugReport, null) - }, - text = { - Text(stringResource(R.string.android_11_bug_dialog_description)) - } - ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 8218713ea0..6c732d7bdb 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -230,8 +230,8 @@ fun PatchesSelectorScreen( // Show selection warning if enabled viewModel.selectionWarningEnabled -> showSelectionWarning = true - // Show universal warning if enabled - viewModel.universalPatchWarningEnabled -> showUniversalWarning = true + // Show universal warning if universal patch is selected and the toggle is off + patch.compatiblePackages == null && viewModel.universalPatchWarningEnabled -> showUniversalWarning = true // Toggle the patch otherwise else -> viewModel.togglePatch(uid, patch) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt index 8fe11934df..3cfffb7beb 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt @@ -1,27 +1,50 @@ package app.revanced.manager.ui.screen.settings.update +import android.Manifest +import android.util.Log +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import app.revanced.manager.R +import app.revanced.manager.domain.manager.SearchForUpdatesBackgroundInterval +import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.ColumnWithScrollbar +import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.UpdatesSettingsViewModel +import app.revanced.manager.util.enabled +import app.revanced.manager.util.permission.PermissionRequestHandler +import app.revanced.manager.util.permission.hasNotificationPermission import app.revanced.manager.util.toast import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -34,6 +57,14 @@ fun UpdatesSettingsScreen( val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + var showBackgroundUpdateDialog by rememberSaveable { mutableStateOf(false) } + + if (showBackgroundUpdateDialog) { + BackgroundBundleUpdateTimeDialog( + onDismiss = { showBackgroundUpdateDialog = false }, + onConfirm = { vm.updateBackgroundBundleUpdateTime(it) } + ) + } Scaffold( topBar = { @@ -89,6 +120,82 @@ fun UpdatesSettingsScreen( headline = R.string.show_manager_update_dialog_on_launch, description = R.string.update_checking_manager_description ) + SettingsListItem( + headlineContent = stringResource(R.string.background_bundle_update), + supportingContent = stringResource(R.string.background_bundle_update_description), + modifier = Modifier.clickable( + onClick = { showBackgroundUpdateDialog = true } + ) + ) } } -} \ No newline at end of file +} + +@Composable +private fun BackgroundBundleUpdateTimeDialog( + onDismiss: () -> Unit, + onConfirm: (SearchForUpdatesBackgroundInterval) -> Unit, + prefs: PreferencesManager = koinInject() +) { + var context = LocalContext.current + var selected by rememberSaveable { mutableStateOf(prefs.searchForUpdatesBackgroundInterval.getBlocking()) } + + var askNotificationPermission by rememberSaveable { mutableStateOf(false) } + + fun onApply() { + onConfirm(selected) + onDismiss() + } + + if (askNotificationPermission) { + PermissionRequestHandler( + contract = ActivityResultContracts.RequestPermission(), + input = Manifest.permission.POST_NOTIFICATIONS, + title = stringResource(R.string.background_bundle_ask_notification), + description = stringResource(R.string.background_bundle_ask_notification_description), + icon = Icons.Outlined.Notifications, + onDismissRequest = { askNotificationPermission = false }, + onResult = { granted -> + askNotificationPermission = false + onApply() + } + ) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.background_radio_menu_title)) }, + text = { + Column { + SearchForUpdatesBackgroundInterval.entries.forEach { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selected = it }, + verticalAlignment = Alignment.CenterVertically + ) { + HapticRadioButton( + selected = selected == it, + onClick = { selected = it }) + Text(stringResource(it.displayName)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + Log.i("testtt", "selected ${selected.toString()}") + Log.i("testtt", "hasNotificationPermission ${context.hasNotificationPermission().toString()}") + if (selected != SearchForUpdatesBackgroundInterval.NEVER && + !context.hasNotificationPermission() + ) askNotificationPermission = true + else + onApply() + } + ) { + Text(stringResource(R.string.apply)) + } + } + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt index 0783e94d63..449bb74011 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt @@ -5,6 +5,7 @@ import android.content.ContentResolver import android.net.Uri import android.os.Build import android.os.PowerManager +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -70,10 +71,6 @@ class DashboardViewModel( downloaderPluginRepository.acknowledgeAllNewPlugins() } - fun dismissUpdateDialog() { - updatedManagerVersion = null - } - private suspend fun checkForManagerUpdates() { if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return @@ -124,8 +121,8 @@ class DashboardViewModel( } } - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = - viewModelScope.launch { patchBundleRepository.createRemote(apiUrl, autoUpdate) } + fun createRemoteSource(apiUrl: String, searchUpdate: Boolean, autoUpdate: Boolean) = + viewModelScope.launch { patchBundleRepository.createRemote(apiUrl, searchUpdate, autoUpdate) } fun delete(bundle: PatchBundleSource) = viewModelScope.launch { patchBundleRepository.remove(bundle) } @@ -144,4 +141,19 @@ class DashboardViewModel( app.toast(app.getString(R.string.bundle_update_unavailable, bundle.getName())) } } + + fun searchUpdate(bundle: PatchBundleSource) = viewModelScope.launch { + if (bundle !is RemotePatchBundle) return@launch + + uiSafe( + app, + R.string.source_download_fail,//TODO + RemotePatchBundle.updateFailMsg + ) { + if (bundle.canUpdateVersion()) + app.toast(app.getString(R.string.bundle_update_success, bundle.getName())) + else + app.toast(app.getString(R.string.bundle_update_unavailable, bundle.getName())) + } + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdatesSettingsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdatesSettingsViewModel.kt index 51764e5067..0de033de67 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdatesSettingsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdatesSettingsViewModel.kt @@ -2,22 +2,34 @@ package app.revanced.manager.ui.viewmodel import android.app.Application import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import app.revanced.manager.R import app.revanced.manager.data.platform.NetworkInfo +import app.revanced.manager.domain.manager.SearchForUpdatesBackgroundInterval import app.revanced.manager.domain.manager.PreferencesManager +import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.util.toast import app.revanced.manager.util.uiSafe +import kotlinx.coroutines.launch class UpdatesSettingsViewModel( - prefs: PreferencesManager, + val prefs: PreferencesManager, private val app: Application, private val reVancedAPI: ReVancedAPI, private val network: NetworkInfo, + private val workerRepository: WorkerRepository ) : ViewModel() { val managerAutoUpdates = prefs.managerAutoUpdates val showManagerUpdateDialogOnLaunch = prefs.showManagerUpdateDialogOnLaunch + fun updateBackgroundBundleUpdateTime(searchForUpdatesBackgroundInterval: SearchForUpdatesBackgroundInterval) { + viewModelScope.launch { + prefs.searchForUpdatesBackgroundInterval.update(searchForUpdatesBackgroundInterval) + workerRepository.scheduleBundleUpdateNotificationWork(searchForUpdatesBackgroundInterval) + } + } + val isConnected: Boolean get() = network.isConnected() diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 2c509aa51c..3772e12a30 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -1,8 +1,12 @@ package app.revanced.manager.util +import android.Manifest +import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Build import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -25,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView +import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -33,6 +38,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import app.revanced.manager.R +import app.revanced.manager.util.permission.PermissionHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/app/revanced/manager/util/permission/PermissionContextExtension.kt b/app/src/main/java/app/revanced/manager/util/permission/PermissionContextExtension.kt new file mode 100644 index 0000000000..62e13ae5fb --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/permission/PermissionContextExtension.kt @@ -0,0 +1,22 @@ +package app.revanced.manager.util.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.os.Build +import app.revanced.manager.util.permission.PermissionHelper.PermissionState + +fun Context.hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + PermissionHelper(this).isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS) + else + true +} + +fun Context.isNotificationPermissionDenied(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + PermissionHelper(this).getPermissionState(this as Activity, Manifest.permission.POST_NOTIFICATIONS) == + PermissionState.DeniedPermanently + else + false +} diff --git a/app/src/main/java/app/revanced/manager/util/permission/PermissionHelper.kt b/app/src/main/java/app/revanced/manager/util/permission/PermissionHelper.kt new file mode 100644 index 0000000000..20aafed7c7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/permission/PermissionHelper.kt @@ -0,0 +1,50 @@ +package app.revanced.manager.util.permission + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit + +class PermissionHelper(private val context: Context) { + private val prefs by lazy { + context.getSharedPreferences("permissions_pref", Context.MODE_PRIVATE) + } + + fun isPermissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + fun isFirstTimeAsking(permission: String): Boolean { + val firstTime = prefs.getBoolean(permission, true) + if (firstTime) { + prefs.edit { putBoolean(permission, false) } + } + return firstTime + } + + fun shouldShowRationale(activity: Activity, permission: String): Boolean { + return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } + + fun resetPermission(permission: String) { + prefs.edit { putBoolean(permission, true) } + } + + fun getPermissionState(activity: Activity, permission: String): PermissionState { + return when { + isPermissionGranted(permission) -> PermissionState.Granted + shouldShowRationale(activity, permission) -> PermissionState.DeniedWithRationale + isFirstTimeAsking(permission) -> PermissionState.FirstTime + else -> PermissionState.DeniedPermanently + } + } + + enum class PermissionState { + Granted, + FirstTime, + DeniedWithRationale, + DeniedPermanently + } +} diff --git a/app/src/main/java/app/revanced/manager/util/permission/RequestPermissionComponent.kt b/app/src/main/java/app/revanced/manager/util/permission/RequestPermissionComponent.kt new file mode 100644 index 0000000000..0ddf2f08ff --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/permission/RequestPermissionComponent.kt @@ -0,0 +1,89 @@ +package app.revanced.manager.util.permission + +import android.app.Activity +import android.content.Context +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.ActivityCompat +import app.revanced.manager.R +import app.revanced.manager.ui.component.AlertDialogExtended + +@Composable +fun PermissionRequestHandler( + contract: ActivityResultContract, + input: String, + onDismissRequest: () -> Unit, + onResult: (Boolean) -> Unit, + onContinue: () -> Unit = {}, + title: String, + description: String, + icon: ImageVector +) { + val context = LocalContext.current + val activity = context as? Activity ?: return + val permissionHelper = PermissionHelper(context) + + val launcher = rememberLauncherForActivityResult(contract) { result -> + Log.d("testtt", "rememberLauncherForActivityResult $result") + onResult(result) + } + + when (permissionHelper.getPermissionState(activity, input)) { + PermissionHelper.PermissionState.Granted -> { + onResult(true) + } + PermissionHelper.PermissionState.FirstTime, + PermissionHelper.PermissionState.DeniedWithRationale -> { + Log.d("testtt", "DeniedWithRationale or FirstTime") + PermissionDialog( + title = title, + description = description, + icon = icon, + onDismissRequest = onDismissRequest, + onContinue = { + onContinue() + launcher.launch(input) + } + ) + } + PermissionHelper.PermissionState.DeniedPermanently -> { + Log.d("testtt", "DeniedPermanently") + //TODO Handle the "go to settings" case if needed + onResult(false) + } + } +} + +@Composable +private fun PermissionDialog( + title: String, + description: String, + icon: ImageVector, + onDismissRequest: () -> Unit, + onContinue: () -> Unit +) { + AlertDialogExtended( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { Text(description) }, + icon = { Icon(icon, null) }, + confirmButton = { + TextButton(onClick = onContinue) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1ab4a8fed..82785210cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,8 @@ Bundle has not been downloaded. Click here to download it Default Unnamed + Bundle update available + A new version %s for the bundle %s was just released Android 11 bug The app installation permission must be granted ahead of time to avoid a bug in the Android 11 system that will negatively affect the user experience. @@ -181,6 +183,15 @@ This is faster and allows Patcher to use more memory. Patcher process memory limit The max amount of memory that the Patcher process can use (in megabytes) + Enable bundles auto update in background + Automatically checks for bundles updates and update them in the background + Check for bundles updates + Stay updated + ReVanced will send you a notification when a bundle was updated. Press "OK" if you wish to be notified. + Never + Every 15 minutes + Hourly + Daily Export debug logs Failed to read logs (exit code %d) Failed to export logs @@ -335,6 +346,8 @@ Source URL Successfully updated %s No update available for %s + Search for update + Search for updates in background and send a notification when an update is available Auto update Automatically update this bundle when ReVanced starts View patches diff --git a/package.json b/package.json index 246f579a8d..7b7287ff4e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "@saithodev/semantic-release-backmerge": "^4.0.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^6.0.3", - "@semantic-release/git": "^10.0.1", + "@semantic-release/git": "^10.0.1" } }