diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 325bc5b31..71217f60b 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,3 +1,31 @@ +# app [2.5.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/v2.5.0-dev.1...v2.5.0-dev.2) (2026-03-23) + + +### Features + +* Show release dates and patch count ([#3185](https://github.com/ReVanced/revanced-manager/issues/3185)) ([d5a5ec6](https://github.com/ReVanced/revanced-manager/commit/d5a5ec62a4c57a8b6bc0fcc23c8b74c65be2c66e)) + +# app [2.5.0-dev.1](https://github.com/ReVanced/revanced-manager/compare/v2.4.1-dev.1...v2.5.0-dev.1) (2026-03-23) + + +### Bug Fixes + +* Update screen crashing ([cd9d2eb](https://github.com/ReVanced/revanced-manager/commit/cd9d2ebd06e0abc5b8b10a752d8c92ca65abc1e3)) +* Weird padding in Update screen ([2e96c58](https://github.com/ReVanced/revanced-manager/commit/2e96c58f7bc3afc9fd636b76a2204b1b995f8da6)) + + +### Features + +* Improve Updates settings screen ([4ce823c](https://github.com/ReVanced/revanced-manager/commit/4ce823c8c0f4de2bea6c07b362741a980b392e79)) + +## app [2.4.1-dev.1](https://github.com/ReVanced/revanced-manager/compare/v2.4.0...v2.4.1-dev.1) (2026-03-23) + + +### Bug Fixes + +* Use ease out quart for screen transitions ([b69f7c2](https://github.com/ReVanced/revanced-manager/commit/b69f7c2ba8d320e10c3558681294f8bac93618ae)) +* Use em-space and bigger bullet symbols in announcement screen ([2538b6a](https://github.com/ReVanced/revanced-manager/commit/2538b6a7553a1d9366d9f2345a6a471381f18d88)) + # app [2.4.0](https://github.com/ReVanced/revanced-manager/compare/v2.3.0...v2.4.0) (2026-03-23) diff --git a/app/gradle.properties b/app/gradle.properties index f8f5a5862..0da65a83c 100644 --- a/app/gradle.properties +++ b/app/gradle.properties @@ -1 +1 @@ -version = 2.4.0 +version = 2.5.0-dev.2 diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/4.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/4.json new file mode 100644 index 000000000..c0b289101 --- /dev/null +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/4.json @@ -0,0 +1,493 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "7a99bbb0f5aa995683850a59bdf9f235", + "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 , 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": "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, '7a99bbb0f5aa995683850a59bdf9f235')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 5f5a7dc10..02ac06b77 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -6,8 +6,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.EaseInOutQuad -import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.EaseOutQuart import androidx.compose.animation.core.tween import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally @@ -76,7 +75,6 @@ import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel - class MainActivity : AppCompatActivity() { @ExperimentalAnimationApi override fun onCreate(savedInstanceState: Bundle?) { @@ -129,24 +127,24 @@ private fun ReVancedManager(vm: MainViewModel) { startDestination = startDestination, enterTransition = { slideInHorizontally( - animationSpec = tween(300, easing = EaseInOutQuad), + animationSpec = tween(300, easing = EaseOutQuart), initialOffsetX = { it }) }, exitTransition = { slideOutHorizontally( - animationSpec = tween(300, easing = EaseOut), + animationSpec = tween(300, easing = EaseOutQuart), targetOffsetX = { -it / 3 }) }, popEnterTransition = { slideInHorizontally( animationSpec = tween( 300, - easing = EaseInOutQuad + easing = EaseOutQuart ), initialOffsetX = { -it / 3 }) }, popExitTransition = { slideOutHorizontally( - animationSpec = tween(300, easing = EaseOut), + animationSpec = tween(300, easing = EaseOutQuart), targetOffsetX = { it }) } ) { @@ -229,7 +227,12 @@ private fun ReVancedManager(vm: MainViewModel) { UpdateScreen( onBackClick = navController::popBackStackSafe, - vm = koinViewModel { parametersOf(data.downloadOnScreenEntry) } + vm = koinViewModel { + parametersOf( + ChangelogSource.Manager, + data.downloadOnScreenEntry + ) + } ) } 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 c571f48ec..e69d02b55 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -26,14 +26,16 @@ 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 = 3, + version = 4, + exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration( from = 2, to = 3, spec = AppDatabase.DeleteTrustedDownloaders::class - ) + ), + AutoMigration(from = 3, to = 4) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt index 9c298f740..85dd7ffc3 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt @@ -23,7 +23,7 @@ interface PatchBundleDao { @Query("DELETE FROM patch_bundles WHERE uid = :uid") suspend fun remove(uid: Int) - @Query("SELECT name, version, auto_update, source FROM patch_bundles WHERE uid = :uid") + @Query("SELECT name, version, auto_update, source, released_at FROM patch_bundles WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? @Upsert diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt index 68acc33e8..7c4b01177 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 @@ -10,5 +10,6 @@ data class PatchBundleEntity( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, @ColumnInfo(name = "source") val source: Source, - @ColumnInfo(name = "auto_update") val autoUpdate: Boolean + @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, + @ColumnInfo(name = "released_at") val releasedAt: Long? = null, ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt index 68859c514..344273d72 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt @@ -26,7 +26,7 @@ interface DownloaderDao { @Query("DELETE FROM downloaders WHERE uid = :uid") suspend fun remove(uid: Int) - @Query("SELECT name, version, auto_update, source FROM downloaders WHERE uid = :uid") + @Query("SELECT name, version, auto_update, source, released_at FROM downloaders WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? @Upsert diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt index 4bbb8e697..a0d558002 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt @@ -10,5 +10,6 @@ data class DownloaderEntity( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, @ColumnInfo(name = "source") val source: Source, - @ColumnInfo(name = "auto_update") val autoUpdate: Boolean + @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, + @ColumnInfo(name = "released_at") val releasedAt: Long? = null ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt index 78d3b80ab..060ed9267 100644 --- a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt @@ -34,5 +34,6 @@ data class SourceProperties( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, @ColumnInfo(name = "source") val source: Source, - @ColumnInfo(name = "auto_update") val autoUpdate: Boolean + @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, + @ColumnInfo(name = "released_at") val releasedAt: Long? = null, ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index d3f0b33d4..6ea6dc0c2 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -26,6 +26,9 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.map import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File @@ -114,7 +117,12 @@ abstract class SourceManager( val newName = src.loaded?.let(::realNameOf).takeIf { it != src.name } ?: return@syncName - updateDb(uid) { it.copy(name = newName) } + updateDb(uid) { + it.copy( + name = newName, + releasedAt = (src as? RemoteSource)?.releasedAt?.toEpochMillis() + ) + } sources[uid] = src.copy(name = newName) } @@ -141,7 +149,7 @@ abstract class SourceManager( private suspend fun createEntity( name: String, source: SourceInfo, - autoUpdate: Boolean = false + autoUpdate: Boolean = false, ) = entityFromProps( uid = generateUid(), @@ -150,6 +158,7 @@ abstract class SourceManager( versionHash = null, source = source, autoUpdate = autoUpdate, + releasedAt = null, ) ).also { dbUpsert(it) @@ -172,6 +181,7 @@ abstract class SourceManager( versionHash = new.versionHash, source = new.source, autoUpdate = new.autoUpdate, + releasedAt = new.releasedAt, ) ) ) @@ -238,7 +248,7 @@ abstract class SourceManager( this@SourceManager.store.state.value.sources.values.filterIsInstance>() .forEach { src -> with(src) { deleteLocalFile() } - updateDb(src.uid) { it.copy(versionHash = null) } + updateDb(src.uid) { it.copy(versionHash = null, releasedAt = null) } } doReload(state) @@ -308,15 +318,15 @@ abstract class SourceManager( async update@{ Log.d(tag, "Updating: ${it.name}") - val newVersion = it.runCatching { + val updateResult = it.runCatching { when { redownload -> downloadLatest() - checkOnly -> getUpdateInfo()?.version + checkOnly -> getUpdateInfo()?.let { info -> RemoteSource.UpdateResult(info.version, info.createdAt) } else -> update() } ?: return@update null } - it to newVersion + it to updateResult } } .awaitAll() @@ -332,7 +342,7 @@ abstract class SourceManager( var hasErrors = false results.forEach { (src, result) -> - result.getOrNull()?.let { newVersionHash -> + result.getOrNull()?.let { updateResult -> if (checkOnly) { outdated.add(src.uid) return@let @@ -340,7 +350,11 @@ abstract class SourceManager( val name = src.loaded?.let(::realNameOf) ?: src.name updateDb(src.uid) { - it.copy(versionHash = newVersionHash, name = name) + it.copy( + versionHash = updateResult.versionHash, + name = name, + releasedAt = updateResult.releasedAt.toEpochMillis() + ) } } result.exceptionOrNull()?.let { @@ -378,4 +392,6 @@ abstract class SourceManager( interface DatabaseEntity { val uid: Int } -} \ No newline at end of file +} + +private fun LocalDateTime.toEpochMillis() = toInstant(TimeZone.UTC).toEpochMilliseconds() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 0c11d3bb9..1c39aee3f 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -30,9 +30,11 @@ import app.revanced.manager.network.downloader.DownloaderPackage import app.revanced.manager.util.PM import dalvik.system.PathClassLoader import kotlinx.coroutines.flow.map +import kotlinx.datetime.toLocalDateTime import java.io.File import java.lang.ref.WeakReference import java.lang.reflect.Modifier +import kotlin.time.Instant import app.revanced.manager.data.room.sources.Source as SourceInfo @OptIn(DownloaderHostApi::class) @@ -61,7 +63,12 @@ class DownloaderRepository( override fun loadEntity(entity: DownloaderEntity): Source = with(entity) { val file = directoryOf(uid).resolve("downloader.jar") val actualName = - entity.name.ifEmpty { app.getString(if (uid == 0) R.string.auto_updates_dialog_downloaders else R.string.source_name_fallback) } + name.ifEmpty { app.getString(if (uid == 0) R.string.auto_updates_dialog_downloaders else R.string.source_name_fallback) } + + val releasedAt = entity.releasedAt?.let { + Instant.fromEpochMilliseconds(it) + .toLocalDateTime(kotlinx.datetime.TimeZone.UTC) + } return when (source) { is SourceInfo.Local -> LocalSource(actualName, uid, null, file, loader) @@ -69,6 +76,7 @@ class DownloaderRepository( actualName, uid, versionHash, + releasedAt, null, file, SourceInfo.API.SENTINEL, @@ -80,6 +88,7 @@ class DownloaderRepository( actualName, uid, versionHash, + releasedAt, null, file, source.url.toString(), @@ -97,7 +106,8 @@ class DownloaderRepository( name = props.name, versionHash = props.versionHash, source = props.source, - autoUpdate = props.autoUpdate + autoUpdate = props.autoUpdate, + releasedAt = props.releasedAt ) override fun realNameOf(loaded: DownloaderPackage) = loaded.name diff --git a/app/src/main/java/app/revanced/manager/domain/repository/ManagerUpdateRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/ManagerUpdateRepository.kt index 558dd9fa3..0edc343b0 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/ManagerUpdateRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/ManagerUpdateRepository.kt @@ -1,22 +1,43 @@ package app.revanced.manager.domain.repository +import app.revanced.manager.BuildConfig import app.revanced.manager.network.api.ReVancedAPI +import app.revanced.manager.network.dto.ReVancedAsset +import app.revanced.manager.network.utils.getOrThrow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.datetime.LocalDateTime class ManagerUpdateRepository( private val reVancedAPI: ReVancedAPI ) { - private val _availableVersion = MutableStateFlow(null) - val availableVersion = _availableVersion.asStateFlow() + private val asset: ReVancedAsset? = null + private val _releasedAt = MutableStateFlow(null) + private val _version = MutableStateFlow(null) + private val _hasUpdate = MutableStateFlow(false) - suspend fun refreshAvailableVersion(): String? { - val version = reVancedAPI.getAppUpdate()?.version - _availableVersion.value = version - return version + val releasedAt = _releasedAt.asStateFlow() + val hasUpdate = _hasUpdate.asStateFlow() + val version = _version.asStateFlow() + + suspend fun refresh(): ReVancedAsset { + val update = reVancedAPI.getLatestAppInfo().getOrThrow() + + _releasedAt.value = update.createdAt + _version.value = update.version + _hasUpdate.value = update.version.removePrefix("v") != BuildConfig.VERSION_NAME + + return update } - fun clearAvailableVersion() { - _availableVersion.value = null + suspend fun getUpdateOrNull(refetch: Boolean = false): ReVancedAsset? { + val asset = if (refetch || asset == null) refresh() else null + return asset.takeIf { _hasUpdate.value } + } + + fun clearState() { + _releasedAt.value = null + _version.value = null + _hasUpdate.value = false } } \ 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 bf13f7eea..4517c2725 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 @@ -15,6 +15,7 @@ import app.revanced.manager.domain.sources.LocalPatchBundle import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.manager.SourceManager import app.revanced.manager.domain.sources.Loader +import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.sources.Source import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchBundle @@ -27,9 +28,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime import java.io.File import kotlin.collections.map import kotlin.text.ifEmpty +import kotlin.time.Instant private typealias Info = PersistentMap @@ -58,12 +63,18 @@ class PatchBundleRepository( val actualName = entity.name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.source_name_fallback) } + val releasedAt = entity.releasedAt?.let { + Instant.fromEpochMilliseconds(it) + .toLocalDateTime(TimeZone.UTC) + } + return when (source) { is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, file, PatchBundleLoader) is SourceInfo.API -> APIPatchBundle( actualName, uid, versionHash, + releasedAt, null, file, SourceInfo.API.SENTINEL, @@ -75,6 +86,7 @@ class PatchBundleRepository( actualName, uid, versionHash, + releasedAt, null, file, source.url.toString(), @@ -92,7 +104,8 @@ class PatchBundleRepository( name = props.name, versionHash = props.versionHash, source = props.source, - autoUpdate = props.autoUpdate + autoUpdate = props.autoUpdate, + releasedAt = props.releasedAt ) override fun realNameOf(loaded: PatchBundle) = loaded.manifestAttributes?.name @@ -173,6 +186,7 @@ class PatchBundleRepository( this[src.uid] = PatchBundleInfo.Global( src.name, bundle.manifestAttributes?.version, + (src as? RemotePatchBundle)?.releasedAt, src.uid, result.getOrThrow().toList() ) diff --git a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt index fca38b254..cc3246712 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File +import kotlinx.datetime.LocalDateTime typealias RemotePatchBundle = RemoteSource typealias JsonPatchBundle = JsonSource @@ -22,17 +23,28 @@ sealed class RemoteSource( name: String, uid: Int, protected val versionHash: String?, + val releasedAt: LocalDateTime?, error: Throwable?, file: File, val endpoint: String, val autoUpdate: Boolean, loader: Loader ) : Source(name, uid, error, file, loader), KoinComponent { + data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) + protected val http: HttpService by inject() protected abstract suspend fun getLatestInfo(): ReVancedAsset - abstract fun copy(error: Throwable? = this.error, name: String = this.name, autoUpdate: Boolean = this.autoUpdate): RemoteSource - override fun copy(error: Throwable?, name: String): RemoteSource = copy(error, name, this.autoUpdate) + abstract fun copy( + error: Throwable? = this.error, + name: String = this.name, + autoUpdate: Boolean = this.autoUpdate, + versionHash: String? = this.versionHash, + releasedAt: LocalDateTime? = this.releasedAt + ): RemoteSource + + override fun copy(error: Throwable?, name: String): RemoteSource = + copy(error, name, this.autoUpdate, this.versionHash, this.releasedAt) private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { outputStream().use { @@ -41,15 +53,17 @@ sealed class RemoteSource( } } - info.version + UpdateResult(info.version, info.createdAt) } /** * Downloads the latest version regardless if there is a new update available. */ suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) - suspend fun ActionContext.getUpdateInfo() = getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } - suspend fun ActionContext.update(): String? = withContext(Dispatchers.IO) { + suspend fun ActionContext.getUpdateInfo() = + getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } + + suspend fun ActionContext.update(): UpdateResult? = withContext(Dispatchers.IO) { getUpdateInfo()?.let { download(it) } } @@ -62,22 +76,30 @@ class JsonSource( name: String, uid: Int, versionHash: String?, + releasedAt: LocalDateTime?, error: Throwable?, file: File, endpoint: String, autoUpdate: Boolean, loader: Loader -) : RemoteSource(name, uid, versionHash, error, file, endpoint, autoUpdate, loader) { +) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { http.request { url(endpoint) }.getOrThrow() } - override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = JsonSource( + override fun copy( + error: Throwable?, + name: String, + autoUpdate: Boolean, + versionHash: String?, + releasedAt: LocalDateTime? + ) = JsonSource( name, uid, versionHash, + releasedAt, error, file, endpoint, @@ -90,20 +112,28 @@ class APISource( name: String, uid: Int, versionHash: String?, + releasedAt: LocalDateTime?, error: Throwable?, file: File, endpoint: String, autoUpdate: Boolean, loader: Loader, private val getUpdate: suspend ReVancedAPI.() -> APIResponse -) : RemoteSource(name, uid, versionHash, error, file, endpoint, autoUpdate, loader) { +) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { private val api: ReVancedAPI by inject() override suspend fun getLatestInfo() = api.getUpdate().getOrThrow() - override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = APISource( + override fun copy( + error: Throwable?, + name: String, + autoUpdate: Boolean, + versionHash: String?, + releasedAt: LocalDateTime? + ) = APISource( name, uid, versionHash, + releasedAt, error, file, endpoint, diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index 67591012b..2942a62af 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -43,9 +43,6 @@ class ReVancedAPI( suspend fun getAnnouncements() = request>("announcements") - suspend fun getAppUpdate() = - getLatestAppInfo().getOrThrow().takeIf { it.version.removePrefix("v") != BuildConfig.VERSION_NAME } - suspend fun getLatestAppInfo() = request("manager${prefs.useManagerPrereleases.prereleaseString()}") diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt index f0c148f38..632e5584e 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt @@ -1,6 +1,7 @@ package app.revanced.manager.patcher.patch import app.revanced.manager.util.PatchSelection +import kotlinx.datetime.LocalDateTime /** * A base class for storing [PatchBundle] metadata. @@ -16,6 +17,11 @@ sealed class PatchBundleInfo { */ abstract val version: String? + /** + * When this bundle was released. Only applicable for remote bundles. + */ + abstract val releasedAt: LocalDateTime? + /** * The unique ID of the bundle. */ @@ -34,6 +40,7 @@ sealed class PatchBundleInfo { data class Global( override val name: String, override val version: String?, + override val releasedAt: LocalDateTime?, override val uid: Int, override val patches: List ) : PatchBundleInfo() { @@ -64,6 +71,7 @@ sealed class PatchBundleInfo { return Scoped( name, this.version, + releasedAt, uid, relevantPatches, compatible, @@ -85,6 +93,7 @@ sealed class PatchBundleInfo { data class Scoped( override val name: String, override val version: String?, + override val releasedAt: LocalDateTime?, override val uid: Int, override val patches: List, val compatible: List, diff --git a/app/src/main/java/app/revanced/manager/ui/component/ChangelogList.kt b/app/src/main/java/app/revanced/manager/ui/component/ChangelogList.kt index 1b9a71909..d2a989adc 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/ChangelogList.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/ChangelogList.kt @@ -3,14 +3,13 @@ package app.revanced.manager.ui.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.layout.width -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Campaign @@ -20,13 +19,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -41,6 +35,7 @@ import app.revanced.manager.util.relativeTime fun ChangelogList( changelogs: LazyPagingItems, modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp) ) { Box( modifier = modifier.fillMaxSize(), @@ -67,7 +62,8 @@ fun ChangelogList( LazyColumnWithScrollbar( modifier = Modifier.fillMaxSize(), - state = listState + state = listState, + contentPadding = contentPadding ) { items( count = changelogs.itemCount, diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/SourceSectionHeader.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/SourceSectionHeader.kt index 403cb5866..022cda9e1 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/SourceSectionHeader.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/SourceSectionHeader.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp @@ -28,6 +30,7 @@ import app.revanced.manager.R import app.revanced.manager.patcher.patch.PatchBundleInfo import app.revanced.manager.ui.component.TooltipIconButton import app.revanced.manager.ui.component.haptics.HapticTriStateCheckbox +import app.revanced.manager.util.relativeTime @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -70,13 +73,24 @@ fun SourceSectionHeader( Text(text = bundle.name) }, supportingContent = { + val patchCount = bundle.patches.size val version = bundle.version?.takeIf { it.isNotBlank() } + val releasedAt = bundle.releasedAt?.relativeTime(LocalContext.current) if (version == null && loadIssue == null) return@ListItem Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { version?.let { Text( - text = it, + text = listOf( + // Show release date only when view-only + if (readOnly && releasedAt != null) "v$it\u2002($releasedAt)" + else it, + pluralStringResource( + R.plurals.patch_count, + patchCount, + patchCount + ) + ).joinToString("\u2002\u2022\u2002"), color = MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AnnouncementScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AnnouncementScreen.kt index ca29550f4..7bd223570 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AnnouncementScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AnnouncementScreen.kt @@ -63,7 +63,7 @@ fun AnnouncementScreen( }, subtitle = { val createDate = announcement.createdAt.toLocalDateTime(TimeZone.UTC).relativeTime(LocalContext.current) - Text("$createDate · ${announcement.author}") + Text("$createDate\u2002\u2022\u2002${announcement.author}") }, navigationIcon = { TooltipIconButton( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt index 278429444..26b4c9f7d 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt @@ -47,9 +47,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,6 +60,7 @@ import app.revanced.manager.R import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.LocalSource +import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.sources.Source import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ConfirmDialog @@ -69,6 +72,8 @@ import app.revanced.manager.ui.component.haptics.HapticSwitch import app.revanced.manager.ui.component.settings.SafeguardBooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.BundleInformationViewModel +import app.revanced.manager.util.relativeTime +import java.util.Locale.getDefault @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -125,7 +130,17 @@ fun BundleInformationScreen( val dot = "\u2022" // • val emSpace = "\u2002" // en space, roughly half character width val separator = "$emSpace$dot$emSpace" - Text("$subtitleAuthor$separator$subtitleVersion") + Text(text = buildAnnotatedString { + append("$subtitleAuthor$separator$subtitleVersion") + src.asRemoteOrNull?.releasedAt?.let { + val releaseDate = it.relativeTime( + LocalContext.current + ).lowercase(getDefault()) + + append("\u2002($releaseDate)") + } + }) + } } else { null 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 ef5f80ae4..df0208bc6 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 @@ -47,7 +47,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -141,7 +140,8 @@ fun DashboardScreen( !suggestedVersionSafeguard } } - val availableUpdate by vm.availableManagerUpdate.collectAsStateWithLifecycle() + val hasUpdate by vm.hasUpdate.collectAsStateWithLifecycle() + val updateVersion by vm.updateVersion.collectAsStateWithLifecycle() val androidContext = LocalContext.current val resources = LocalResources.current val logoPainter = rememberDrawablePainter(drawable = remember(resources) { @@ -207,12 +207,12 @@ fun DashboardScreen( } var showUpdateDialog by rememberSaveable { mutableStateOf(true) } - if (managerAutoUpdates && showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) { + if (managerAutoUpdates && showUpdateDialog && showManagerUpdateDialogOnLaunch && hasUpdate) { AvailableUpdateDialog( onDismiss = { showUpdateDialog = false }, setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch, onConfirm = onUpdateClick, - newVersion = availableUpdate!! + newVersion = updateVersion!! ) } @@ -337,7 +337,7 @@ fun DashboardScreen( } }, actions = { - if (availableUpdate != null) { + if (updateVersion != null) { TooltipIconButton( onClick = onUpdateClick, tooltip = stringResource(R.string.update), diff --git a/app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt index fab0ecf6e..88ade31ca 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt @@ -110,12 +110,14 @@ fun UpdateScreen( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Column( - modifier = Modifier.padding(paddingValues).fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) { if (vm.state == State.DOWNLOADING) LinearWavyProgressIndicator( progress = { vm.downloadProgress }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(top = paddingValues.calculateTopPadding()) + .fillMaxWidth(), ) AnimatedVisibility(visible = vm.showInternetCheckDialog) { @@ -125,7 +127,10 @@ fun UpdateScreen( ) } - ChangelogList(changelogs = changelogs, modifier = Modifier.padding(paddingValues)) + ChangelogList( + changelogs = changelogs, + contentPadding = paddingValues + ) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt index 7c4faa90c..1c9efbe4d 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.SignalWifiOff @@ -35,7 +34,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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 androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R @@ -50,8 +51,10 @@ import app.revanced.manager.ui.component.haptics.HapticSwitch import app.revanced.manager.ui.component.settings.SafeguardBooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.DownloadsViewModel +import app.revanced.manager.util.relativeTime import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import java.util.Locale.getDefault @OptIn( ExperimentalMaterial3Api::class, @@ -105,7 +108,20 @@ fun DownloaderInfoScreen( MediumFlexibleTopAppBar( title = { Text(appName) }, subtitle = version.takeIf { it.isNotEmpty() }?.let { - { Text("v$it") } + { + Text( + text = buildAnnotatedString { + append("v$it") + if (remote?.releasedAt != null) { + val releaseDate = remote.releasedAt.relativeTime( + LocalContext.current + ).lowercase(getDefault()) + + append("\u2002($releaseDate)") + } + } + ) + } }, navigationIcon = { TooltipIconButton( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index bc59ae4aa..5f7ca9374 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -43,11 +43,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.data.room.apps.downloaded.DownloadedApp +import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.sources.Source.State import app.revanced.manager.network.downloader.DownloaderPackage @@ -62,8 +67,10 @@ import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.sources.ImportSourceDialog import app.revanced.manager.ui.component.sources.ImportSourceDialogStrings import app.revanced.manager.ui.viewmodel.DownloadsViewModel +import app.revanced.manager.util.relativeTime import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import java.util.Locale.getDefault private enum class DownloadsTab( val titleResId: Int, @@ -317,16 +324,28 @@ private fun DownloaderItem( Text(source.name, style = MaterialTheme.typography.bodyLarge) }, supportingContent = { + val stateText = + when (source.state) { + is State.Available<*> -> null + is State.Failed -> R.string.downloader_state_failed + is State.Missing -> R.string.downloader_state_missing + } + + val version = source.loaded?.version + val relativeTime = + (source.asRemoteOrNull)?.releasedAt?.relativeTime(LocalContext.current) + Text( - stringResource( - when (source.state) { - is State.Available<*> -> R.string.downloader_state_loaded - is State.Failed -> R.string.downloader_state_failed - is State.Missing -> R.string.downloader_state_missing + text = buildAnnotatedString { + append(if (relativeTime != null) "v$version\u2002($relativeTime)" else "v$version") + // Error colored state text shown when state isn't available + if (stateText != null) withStyle(SpanStyle(color = MaterialTheme.colorScheme.error)) { + append("\u2002\u2022\u2002") + append(stringResource(stateText)) } - ) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - }, - trailingContent = source.loaded?.version?.let { @Composable { Text(it) } } + } ) -} \ No newline at end of file +} 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 3527c1638..525735ca1 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 @@ -3,6 +3,7 @@ package app.revanced.manager.ui.screen.settings.update import androidx.appcompat.content.res.AppCompatResources import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,7 +19,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.WorkOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -55,7 +55,7 @@ import app.revanced.manager.ui.component.TooltipIconButton import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.SafeguardBooleanItem import app.revanced.manager.ui.viewmodel.UpdatesSettingsViewModel -import app.revanced.manager.util.toast +import app.revanced.manager.util.relativeTime import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -69,10 +69,11 @@ fun UpdatesSettingsScreen( vm: UpdatesSettingsViewModel = koinViewModel(), ) { val context = LocalContext.current - val resources = LocalResources.current val coroutineScope = rememberCoroutineScope() var checkingForUpdate by remember { mutableStateOf(false) } - val availableUpdate by vm.availableManagerUpdate.collectAsStateWithLifecycle() + val managerVersion by vm.managerVersion.collectAsStateWithLifecycle() + val hasUpdate by vm.hasUpdate.collectAsStateWithLifecycle() + val updateReleasedAt by vm.updateReleasedAt.collectAsStateWithLifecycle() val scrollState = androidx.compose.foundation.rememberScrollState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( canScroll = { @@ -112,15 +113,14 @@ fun UpdatesSettingsScreen( enabled = !checkingForUpdate, onClick = { coroutineScope.launch { - if (availableUpdate != null) { + if (hasUpdate) { onUpdateClick() return@launch } checkingForUpdate = true try { - val version = vm.checkForUpdates() - if (!version.isNullOrEmpty()) onUpdateClick() + if (vm.checkUpdates()) onUpdateClick() } finally { checkingForUpdate = false } @@ -144,7 +144,7 @@ fun UpdatesSettingsScreen( text = stringResource( when { checkingForUpdate -> R.string.update_check - availableUpdate != null -> R.string.view_update + hasUpdate -> R.string.view_update else -> R.string.manual_update_check } ) @@ -164,7 +164,13 @@ fun UpdatesSettingsScreen( ) { ListSection( title = stringResource(R.string.manager), - leadingContent = { Icon(Icons.Outlined.WorkOutline, contentDescription = null, modifier = Modifier.size(18.dp)) } + leadingContent = { + Icon( + Icons.Outlined.WorkOutline, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } ) { Surface( shape = RoundedCornerShape(4.dp), @@ -190,11 +196,27 @@ fun UpdatesSettingsScreen( .padding(start = 4.dp) ) Spacer(modifier = Modifier.width(16.dp)) - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + managerVersion?.let { version -> + updateReleasedAt?.let { + val releasedAt = it.relativeTime(LocalContext.current) + + Text( + text = "$version\u2002\u2022\u2002$releasedAt", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } Spacer(modifier = Modifier.height(4.dp)) Button( @@ -242,6 +264,7 @@ fun UpdatesSettingsScreen( coroutineScope.launch { vm.useManagerPrereleases.update(value) vm.clearAvailableManagerUpdate() + vm.checkUpdates(false) } } ) 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 77274461b..34e2cfcff 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 @@ -40,7 +40,8 @@ class DashboardViewModel( val bundleDownloadError = patchBundleRepository.apiOutageError private val contentResolver: ContentResolver = app.contentResolver - val availableManagerUpdate = managerUpdateRepository.availableVersion + val hasUpdate = managerUpdateRepository.hasUpdate + val updateVersion = managerUpdateRepository.version val sourcesNotDownloaded = patchBundleRepository.bundleInfoFlow.map { it.isEmpty() } val sourceUpdatesAvailable = combine( @@ -76,7 +77,7 @@ class DashboardViewModel( if (!prefs.managerAutoUpdates.get()) return uiSafe(app, R.string.failed_to_check_updates, "Failed to check for updates") { - managerUpdateRepository.refreshAvailableVersion() + managerUpdateRepository.refresh() } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index a2ad64edb..db0c4f174 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -18,6 +18,7 @@ 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 @@ -375,6 +376,7 @@ private fun PatchSelection.toPersistentPatchSelection(): PersistentPatchSelectio private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped( name = name, version = version, + releasedAt = releasedAt, uid = uid, patches = patches, compatible = patches, @@ -385,6 +387,7 @@ private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped( private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped( name = name, version = version, + releasedAt = (this.asRemoteOrNull)?.releasedAt, uid = uid, patches = emptyList(), compatible = emptyList(), diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt index 78f97cca1..a7a5af5b8 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -19,6 +19,7 @@ import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.repository.ChangelogsRepository +import app.revanced.manager.domain.repository.ManagerUpdateRepository import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.dto.ReVancedAssetHistory @@ -31,9 +32,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import org.koin.core.component.inject import ru.solrudev.ackpine.installer.InstallFailure import ru.solrudev.ackpine.installer.PackageInstaller import ru.solrudev.ackpine.installer.createSession @@ -44,15 +42,14 @@ import ru.solrudev.ackpine.session.parameters.Confirmation class UpdateViewModel( private val api: ReVancedAPI, private val source: ChangelogSource, - private val downloadOnScreenEntry: Boolean -) : ViewModel(), KoinComponent { - private val app: Application by inject() - private val reVancedAPI: ReVancedAPI by inject() - private val http: HttpService by inject() - private val networkInfo: NetworkInfo by inject() - private val fs: Filesystem by inject() - private val ackpineInstaller: PackageInstaller = get() - + private val downloadOnScreenEntry: Boolean, + private val app: Application, + private val http: HttpService, + private val networkInfo: NetworkInfo, + private val fs: Filesystem, + private val ackpineInstaller: PackageInstaller, + private val managerUpdateRepository: ManagerUpdateRepository, +) : ViewModel() { // TODO: save state to handle process death. var downloadedSize by mutableLongStateOf(0L) private set @@ -85,7 +82,7 @@ class UpdateViewModel( init { viewModelScope.launch { uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { - releaseInfo = reVancedAPI.getAppUpdate() + releaseInfo = managerUpdateRepository.getUpdateOrNull() ?: throw Exception("No update available") if (downloadOnScreenEntry) { 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 55394055f..76a438662 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 @@ -18,22 +18,25 @@ class UpdatesSettingsViewModel( val managerAutoUpdates = prefs.managerAutoUpdates val showManagerUpdateDialogOnLaunch = prefs.showManagerUpdateDialogOnLaunch val useManagerPrereleases = prefs.useManagerPrereleases - val availableManagerUpdate = managerUpdateRepository.availableVersion + val managerVersion = managerUpdateRepository.version + val updateReleasedAt = managerUpdateRepository.releasedAt + val hasUpdate = managerUpdateRepository.hasUpdate - suspend fun checkForUpdates(): String? { - var availableVersion: String? = null + suspend fun checkUpdates(showToast: Boolean = true): Boolean { + var hasUpdate = false uiSafe(app, R.string.failed_to_check_updates, "Failed to check for updates") { - availableVersion = managerUpdateRepository.refreshAvailableVersion() - - if (availableVersion == null) + if (managerUpdateRepository.getUpdateOrNull(true) != null) { + hasUpdate = true + } else if (showToast) { app.toast(app.getString(R.string.no_update_available)) + } } - return availableVersion + return hasUpdate } fun clearAvailableManagerUpdate() { - managerUpdateRepository.clearAvailableVersion() + managerUpdateRepository.clearState() } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 553548513..7d6ceb29c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -491,6 +491,7 @@ It’s only compatible with these versions: %2$s Use pre-releases? Pre-release versions may be unstable and contain bugs. You may experience crashes, data loss, or other unexpected issues. View changelog + Updated %s Loading changelog… Couldn’t download changelog: %s Battery optimizations must be turned off in order for ReVanced Manager to work correctly in the background