diff --git a/README.md b/README.md index 53323df5d..4f5ffa4df 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Ackpine depends on Jetpack libraries, so it's necessary to declare the `google() ```kotlin dependencies { - val ackpineVersion = "0.7.6" + val ackpineVersion = "0.8.0" implementation("ru.solrudev.ackpine:ackpine-core:$ackpineVersion") // optional - Kotlin extensions and Coroutines support diff --git a/ackpine-core/api/ackpine-core.api b/ackpine-core/api/ackpine-core.api index 050cc98f3..e2ee69cd5 100644 --- a/ackpine-core/api/ackpine-core.api +++ b/ackpine-core/api/ackpine-core.api @@ -458,14 +458,18 @@ public abstract interface class ru/solrudev/ackpine/session/parameters/Confirmat public abstract fun getNotificationData ()Lru/solrudev/ackpine/session/parameters/NotificationData; } +public abstract interface class ru/solrudev/ackpine/session/parameters/DrawableId : java/io/Serializable { + public abstract fun drawableId ()I +} + public final class ru/solrudev/ackpine/session/parameters/NotificationData { public static final field Companion Lru/solrudev/ackpine/session/parameters/NotificationData$Companion; public static final field DEFAULT Lru/solrudev/ackpine/session/parameters/NotificationData; - public synthetic fun (ILru/solrudev/ackpine/session/parameters/NotificationString;Lru/solrudev/ackpine/session/parameters/NotificationString;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lru/solrudev/ackpine/session/parameters/DrawableId;Lru/solrudev/ackpine/resources/ResolvableString;Lru/solrudev/ackpine/resources/ResolvableString;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z - public final fun getContentText ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public final fun getIcon ()I - public final fun getTitle ()Lru/solrudev/ackpine/session/parameters/NotificationString; + public final fun getContentText ()Lru/solrudev/ackpine/resources/ResolvableString; + public final fun getIcon ()Lru/solrudev/ackpine/session/parameters/DrawableId; + public final fun getTitle ()Lru/solrudev/ackpine/resources/ResolvableString; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -473,12 +477,12 @@ public final class ru/solrudev/ackpine/session/parameters/NotificationData { public final class ru/solrudev/ackpine/session/parameters/NotificationData$Builder { public fun ()V public final fun build ()Lru/solrudev/ackpine/session/parameters/NotificationData; - public final fun getContentText ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public final fun getIcon ()I - public final fun getTitle ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public final fun setContentText (Lru/solrudev/ackpine/session/parameters/NotificationString;)Lru/solrudev/ackpine/session/parameters/NotificationData$Builder; - public final fun setIcon (I)Lru/solrudev/ackpine/session/parameters/NotificationData$Builder; - public final fun setTitle (Lru/solrudev/ackpine/session/parameters/NotificationString;)Lru/solrudev/ackpine/session/parameters/NotificationData$Builder; + public final fun getContentText ()Lru/solrudev/ackpine/resources/ResolvableString; + public final fun getIcon ()Lru/solrudev/ackpine/session/parameters/DrawableId; + public final fun getTitle ()Lru/solrudev/ackpine/resources/ResolvableString; + public final fun setContentText (Lru/solrudev/ackpine/resources/ResolvableString;)Lru/solrudev/ackpine/session/parameters/NotificationData$Builder; + public final fun setIcon (Lru/solrudev/ackpine/session/parameters/DrawableId;)Lru/solrudev/ackpine/session/parameters/NotificationData$Builder; + public final fun setTitle (Lru/solrudev/ackpine/resources/ResolvableString;)Lru/solrudev/ackpine/session/parameters/NotificationData$Builder; } public final class ru/solrudev/ackpine/session/parameters/NotificationData$Companion { diff --git a/ackpine-core/build.gradle.kts b/ackpine-core/build.gradle.kts index 851494816..9679fd64e 100644 --- a/ackpine-core/build.gradle.kts +++ b/ackpine-core/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ dependencies { api(androidx.annotation) api(androidx.startup) api(libs.listenablefuture) + api(projects.ackpineResources) implementation(projects.ackpineRuntime) implementation(androidx.concurrent.futures.core) implementation(androidx.core.ktx) diff --git a/ackpine-core/consumer-rules.pro b/ackpine-core/consumer-rules.pro index f7b1e6542..e0bbe619f 100644 --- a/ackpine-core/consumer-rules.pro +++ b/ackpine-core/consumer-rules.pro @@ -5,6 +5,8 @@ -keep class ru.solrudev.ackpine.session.parameters.Empty { *; } -keep class ru.solrudev.ackpine.session.parameters.Raw { *; } -keep class ru.solrudev.ackpine.session.parameters.Resource { *; } +-keep interface ru.solrudev.ackpine.session.parameters.DrawableId { *; } +-keep class * implements ru.solrudev.ackpine.session.parameters.DrawableId { *; } -keep class ru.solrudev.ackpine.installer.InstallFailure { *; } -keep class ru.solrudev.ackpine.installer.InstallFailure$* { *; } -keep class ru.solrudev.ackpine.uninstaller.UninstallFailure { *; } diff --git a/ackpine-core/schemas/ru.solrudev.ackpine.impl.database.AckpineDatabase/8.json b/ackpine-core/schemas/ru.solrudev.ackpine.impl.database.AckpineDatabase/8.json new file mode 100644 index 000000000..a8ead51d0 --- /dev/null +++ b/ackpine-core/schemas/ru.solrudev.ackpine.impl.database.AckpineDatabase/8.json @@ -0,0 +1,586 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "22d39a00bd2f00f0739bd5c264abfda9", + "entities": [ + { + "tableName": "sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `state` TEXT NOT NULL, `confirmation` TEXT NOT NULL, `notification_title` BLOB NOT NULL, `notification_text` BLOB NOT NULL, `notification_icon` BLOB NOT NULL, `require_user_action` INTEGER NOT NULL DEFAULT true, `last_launch_timestamp` INTEGER NOT NULL DEFAULT 0, `last_commit_timestamp` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "confirmation", + "columnName": "confirmation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationTitle", + "columnName": "notification_title", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "notificationText", + "columnName": "notification_text", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "notificationIcon", + "columnName": "notification_icon", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "requireUserAction", + "columnName": "require_user_action", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "lastLaunchTimestamp", + "columnName": "last_launch_timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCommitTimestamp", + "columnName": "last_commit_timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sessions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_sessions_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_sessions_last_launch_timestamp", + "unique": false, + "columnNames": [ + "last_launch_timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_last_launch_timestamp` ON `${TABLE_NAME}` (`last_launch_timestamp`)" + }, + { + "name": "index_sessions_last_commit_timestamp", + "unique": false, + "columnNames": [ + "last_commit_timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_last_commit_timestamp` ON `${TABLE_NAME}` (`last_commit_timestamp`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "sessions_installer_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `installer_type` TEXT NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installerType", + "columnName": "installer_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_install_failures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `failure` BLOB NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "failure", + "columnName": "failure", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_uninstall_failures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `failure` BLOB NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "failure", + "columnName": "failure", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_install_uris", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` TEXT NOT NULL, `uri` TEXT NOT NULL, FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sessions_install_uris_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_install_uris_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_package_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sessions_package_names_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_package_names_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `progress` INTEGER NOT NULL DEFAULT 0, `max` INTEGER NOT NULL DEFAULT 100, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "max", + "columnName": "max", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "100" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_native_session_ids", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `native_session_id` INTEGER NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nativeSessionId", + "columnName": "native_session_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_notification_ids", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `notification_id` INTEGER NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_install_modes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `install_mode` TEXT NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installMode", + "columnName": "install_mode", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sessions_last_install_timestamps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `last_update_timestamp` INTEGER NOT NULL, PRIMARY KEY(`session_id`), FOREIGN KEY(`session_id`) REFERENCES `sessions`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdateTimestamp", + "columnName": "last_update_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, '22d39a00bd2f00f0739bd5c264abfda9')" + ] + } +} \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/Ackpine.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/Ackpine.kt index 49aaf2661..7307c0774 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/Ackpine.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/Ackpine.kt @@ -39,7 +39,7 @@ public object Ackpine { private val configurationChangesCallback = object : ComponentCallbacks { override fun onConfigurationChanged(newConfig: Configuration) = createNotificationChannel() - override fun onLowMemory() {} + override fun onLowMemory() { /* no-op */ } } /** diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/DisposableSubscription.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/DisposableSubscription.kt index 6f80064d3..51faceffa 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/DisposableSubscription.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/DisposableSubscription.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,5 +75,5 @@ public class DisposableSubscriptionContainer : DisposableSubscription { @RestrictTo(RestrictTo.Scope.LIBRARY) internal data object DummyDisposableSubscription : DisposableSubscription { override val isDisposed: Boolean = true - override fun dispose() {} + override fun dispose() { /* no-op */ } } \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabase.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabase.kt index 7efa1a956..633c91de5 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabase.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabase.kt @@ -26,8 +26,9 @@ import androidx.room.TypeConverters import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import ru.solrudev.ackpine.impl.database.converters.DrawableIdConverters import ru.solrudev.ackpine.impl.database.converters.InstallFailureConverters -import ru.solrudev.ackpine.impl.database.converters.NotificationStringConverters +import ru.solrudev.ackpine.impl.database.converters.ResolvableStringConverters import ru.solrudev.ackpine.impl.database.converters.UninstallFailureConverters import ru.solrudev.ackpine.impl.database.dao.InstallSessionDao import ru.solrudev.ackpine.impl.database.dao.LastUpdateTimestampDao @@ -79,11 +80,16 @@ private const val PURGE_SQL = "DELETE FROM sessions WHERE state IN $TERMINAL_STA AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7) ], - version = 7, + version = 8, exportSchema = true ) @TypeConverters( - value = [InstallFailureConverters::class, UninstallFailureConverters::class, NotificationStringConverters::class] + value = [ + InstallFailureConverters::class, + UninstallFailureConverters::class, + ResolvableStringConverters::class, + DrawableIdConverters::class + ] ) internal abstract class AckpineDatabase : RoomDatabase() { @@ -132,7 +138,7 @@ internal abstract class AckpineDatabase : RoomDatabase() { } .setQueryExecutor(executor) .addCallback(PurgeCallback) - .addMigrations(Migration_4_5) + .addMigrations(Migration_4_5, Migration_7_8) .fallbackToDestructiveMigration() .build() } diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabaseMigrations.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabaseMigrations.kt index 9d13f4142..ae69aafe7 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabaseMigrations.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/AckpineDatabaseMigrations.kt @@ -23,40 +23,67 @@ import androidx.sqlite.db.SupportSQLiteDatabase @RestrictTo(RestrictTo.Scope.LIBRARY) internal object Migration_4_5 : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) = db.run { - val supportsDeferForeignKeys = Build.VERSION.SDK_INT >= 21 - try { - if (!supportsDeferForeignKeys) { - execSQL("PRAGMA foreign_keys = FALSE") - } - beginTransaction() - if (supportsDeferForeignKeys) { - execSQL("PRAGMA defer_foreign_keys = TRUE") - } - execSQL("DROP TABLE sessions") - execSQL("CREATE TABLE IF NOT EXISTS sessions (id TEXT NOT NULL, type TEXT NOT NULL, state TEXT NOT NULL, confirmation TEXT NOT NULL, notification_title BLOB NOT NULL, notification_text BLOB NOT NULL, notification_icon INTEGER NOT NULL, require_user_action INTEGER NOT NULL DEFAULT true, last_launch_timestamp INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(id))") - execSQL("CREATE INDEX IF NOT EXISTS index_sessions_type ON sessions (type)") - execSQL("CREATE INDEX IF NOT EXISTS index_sessions_state ON sessions (state)") - execSQL("CREATE INDEX IF NOT EXISTS index_sessions_last_launch_timestamp ON sessions (last_launch_timestamp)") - execSQL("DELETE FROM sessions_installer_types") - execSQL("DELETE FROM sessions_install_failures") - execSQL("DELETE FROM sessions_uninstall_failures") - execSQL("DELETE FROM sessions_install_uris") - execSQL("DELETE FROM sessions_package_names") - execSQL("DELETE FROM sessions_progress") - execSQL("DELETE FROM sessions_native_session_ids") - execSQL("DELETE FROM sessions_notification_ids") - execSQL("DELETE FROM sessions_names") - setTransactionSuccessful() - } finally { - endTransaction() - if (!supportsDeferForeignKeys) { - execSQL("PRAGMA foreign_keys = TRUE") - } - query("PRAGMA wal_checkpoint(FULL)").close() - if (!inTransaction()) { - execSQL("VACUUM") - } + override fun migrate(db: SupportSQLiteDatabase) = db.migrate { + execSQL("DROP TABLE sessions") + execSQL("CREATE TABLE IF NOT EXISTS sessions (id TEXT NOT NULL, type TEXT NOT NULL, state TEXT NOT NULL, confirmation TEXT NOT NULL, notification_title BLOB NOT NULL, notification_text BLOB NOT NULL, notification_icon INTEGER NOT NULL, require_user_action INTEGER NOT NULL DEFAULT true, last_launch_timestamp INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(id))") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_type ON sessions (type)") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_state ON sessions (state)") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_last_launch_timestamp ON sessions (last_launch_timestamp)") + execSQL("DELETE FROM sessions_installer_types") + execSQL("DELETE FROM sessions_install_failures") + execSQL("DELETE FROM sessions_uninstall_failures") + execSQL("DELETE FROM sessions_install_uris") + execSQL("DELETE FROM sessions_package_names") + execSQL("DELETE FROM sessions_progress") + execSQL("DELETE FROM sessions_native_session_ids") + execSQL("DELETE FROM sessions_notification_ids") + execSQL("DELETE FROM sessions_names") + } +} + +@RestrictTo(RestrictTo.Scope.LIBRARY) +internal object Migration_7_8 : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) = db.migrate { + execSQL("DROP TABLE sessions") + execSQL("CREATE TABLE IF NOT EXISTS sessions (id TEXT NOT NULL, type TEXT NOT NULL, state TEXT NOT NULL, confirmation TEXT NOT NULL, notification_title BLOB NOT NULL, notification_text BLOB NOT NULL, notification_icon BLOB NOT NULL, require_user_action INTEGER NOT NULL DEFAULT true, last_launch_timestamp INTEGER NOT NULL DEFAULT 0, last_commit_timestamp INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(id))") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_type ON sessions (type)") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_state ON sessions (state)") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_last_launch_timestamp ON sessions (last_launch_timestamp)") + execSQL("CREATE INDEX IF NOT EXISTS index_sessions_last_commit_timestamp ON sessions (last_commit_timestamp)") + execSQL("DELETE FROM sessions_installer_types") + execSQL("DELETE FROM sessions_install_failures") + execSQL("DELETE FROM sessions_uninstall_failures") + execSQL("DELETE FROM sessions_install_uris") + execSQL("DELETE FROM sessions_package_names") + execSQL("DELETE FROM sessions_progress") + execSQL("DELETE FROM sessions_native_session_ids") + execSQL("DELETE FROM sessions_notification_ids") + execSQL("DELETE FROM sessions_names") + execSQL("DELETE FROM sessions_install_modes") + execSQL("DELETE FROM sessions_last_install_timestamps") + } +} + +private inline fun SupportSQLiteDatabase.migrate(actions: SupportSQLiteDatabase.() -> Unit) { + val supportsDeferForeignKeys = Build.VERSION.SDK_INT >= 21 + try { + if (!supportsDeferForeignKeys) { + execSQL("PRAGMA foreign_keys = FALSE") + } + beginTransaction() + if (supportsDeferForeignKeys) { + execSQL("PRAGMA defer_foreign_keys = TRUE") + } + actions() + setTransactionSuccessful() + } finally { + endTransaction() + if (!supportsDeferForeignKeys) { + execSQL("PRAGMA foreign_keys = TRUE") + } + query("PRAGMA wal_checkpoint(FULL)").close() + if (!inTransaction()) { + execSQL("VACUUM") } } } \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/converters/Converters.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/converters/Converters.kt index d6137c178..43bd1b724 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/converters/Converters.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/converters/Converters.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,18 +18,30 @@ package ru.solrudev.ackpine.impl.database.converters import androidx.room.TypeConverter import ru.solrudev.ackpine.installer.InstallFailure -import ru.solrudev.ackpine.session.parameters.NotificationString +import ru.solrudev.ackpine.resources.ResolvableString +import ru.solrudev.ackpine.session.parameters.DrawableId import ru.solrudev.ackpine.uninstaller.UninstallFailure -internal object NotificationStringConverters { +internal object DrawableIdConverters { @TypeConverter @JvmStatic - internal fun fromByteArray(byteArray: ByteArray): NotificationString = byteArray.deserialize() + internal fun fromByteArray(byteArray: ByteArray): DrawableId = byteArray.deserialize() @TypeConverter @JvmStatic - internal fun toByteArray(notificationString: NotificationString): ByteArray = notificationString.serialize() + internal fun toByteArray(drawableId: DrawableId): ByteArray = drawableId.serialize() +} + +internal object ResolvableStringConverters { + + @TypeConverter + @JvmStatic + internal fun fromByteArray(byteArray: ByteArray): ResolvableString = byteArray.deserialize() + + @TypeConverter + @JvmStatic + internal fun toByteArray(resolvableString: ResolvableString): ByteArray = resolvableString.serialize() } internal object InstallFailureConverters { diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/model/SessionEntity.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/model/SessionEntity.kt index 739944937..11eb09487 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/model/SessionEntity.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/database/model/SessionEntity.kt @@ -16,7 +16,6 @@ package ru.solrudev.ackpine.impl.database.model -import androidx.annotation.DrawableRes import androidx.annotation.RestrictTo import androidx.room.ColumnInfo import androidx.room.Embedded @@ -24,8 +23,9 @@ import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.Relation import ru.solrudev.ackpine.installer.parameters.InstallerType +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.session.parameters.Confirmation -import ru.solrudev.ackpine.session.parameters.NotificationString +import ru.solrudev.ackpine.session.parameters.DrawableId @RestrictTo(RestrictTo.Scope.LIBRARY) @Entity(tableName = "sessions") @@ -40,12 +40,11 @@ internal data class SessionEntity internal constructor( @ColumnInfo(name = "confirmation") val confirmation: Confirmation, @ColumnInfo(name = "notification_title") - val notificationTitle: NotificationString, + val notificationTitle: ResolvableString, @ColumnInfo(name = "notification_text") - val notificationText: NotificationString, - @DrawableRes + val notificationText: ResolvableString, @ColumnInfo(name = "notification_icon") - val notificationIcon: Int, + val notificationIcon: DrawableId, @ColumnInfo(name = "require_user_action", defaultValue = "true") val requireUserAction: Boolean, @ColumnInfo(name = "last_launch_timestamp", defaultValue = "0", index = true) diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/InstallSessionFactory.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/InstallSessionFactory.kt index 0236c7afb..7023df323 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/InstallSessionFactory.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/InstallSessionFactory.kt @@ -33,11 +33,12 @@ import ru.solrudev.ackpine.impl.installer.session.SessionBasedInstallSession import ru.solrudev.ackpine.installer.InstallFailure import ru.solrudev.ackpine.installer.parameters.InstallParameters import ru.solrudev.ackpine.installer.parameters.InstallerType +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.session.Progress import ru.solrudev.ackpine.session.ProgressSession import ru.solrudev.ackpine.session.Session +import ru.solrudev.ackpine.session.parameters.DEFAULT_NOTIFICATION_STRING import ru.solrudev.ackpine.session.parameters.NotificationData -import ru.solrudev.ackpine.session.parameters.NotificationString import java.util.UUID import java.util.concurrent.Executor @@ -55,6 +56,8 @@ internal interface InstallSessionFactory { lastUpdateTimestamp: Long = Long.MAX_VALUE, needToCompleteIfSucceeded: Boolean = false ): ProgressSession + + fun resolveNotificationData(notificationData: NotificationData, name: String): NotificationData } @RestrictTo(RestrictTo.Scope.LIBRARY) @@ -86,7 +89,7 @@ internal class InstallSessionFactoryImpl internal constructor( apk = parameters.apks.toList().singleOrNull() ?: throw SplitPackagesNotSupportedException(), id, initialState, initialProgress, parameters.confirmation, - parameters.notificationData.resolveDefault(parameters.name), + resolveNotificationData(parameters.notificationData, parameters.name), lastUpdateTimestampDao, sessionDao, sessionFailureDao, sessionProgressDao, executor, handler, notificationId, packageName, lastUpdateTimestamp, needToCompleteIfSucceeded, @@ -98,7 +101,7 @@ internal class InstallSessionFactoryImpl internal constructor( apks = parameters.apks.toList(), id, initialState, initialProgress, parameters.confirmation, - parameters.notificationData.resolveDefault(parameters.name), + resolveNotificationData(parameters.notificationData, parameters.name), parameters.requireUserAction, parameters.installMode, sessionDao, sessionFailureDao, sessionProgressDao, nativeSessionIdDao, @@ -106,20 +109,41 @@ internal class InstallSessionFactoryImpl internal constructor( ) } - private fun NotificationData.resolveDefault(name: String): NotificationData = NotificationData.Builder() - .setTitle( - title.takeUnless { it.isDefault } ?: NotificationString.resource(R.string.ackpine_prompt_install_title) - ) - .setContentText( - contentText.takeUnless { it.isDefault } ?: resolveDefaultContentText(name) - ) - .setIcon(icon) - .build() + override fun resolveNotificationData(notificationData: NotificationData, name: String) = notificationData.run { + NotificationData.Builder() + .setTitle( + title.takeUnless { it === DEFAULT_NOTIFICATION_STRING } ?: AckpinePromptInstallTitle + ) + .setContentText( + contentText.takeUnless { it === DEFAULT_NOTIFICATION_STRING } ?: resolveDefaultContentText(name) + ) + .setIcon(icon) + .build() + } - private fun resolveDefaultContentText(name: String): NotificationString { + private fun resolveDefaultContentText(name: String): ResolvableString { if (name.isNotEmpty()) { - return NotificationString.resource(R.string.ackpine_prompt_install_message_with_label, name) + return AckpinePromptInstallMessageWithLabel(name) } - return NotificationString.resource(R.string.ackpine_prompt_install_message) + return AckpinePromptInstallMessage + } +} + +private object AckpinePromptInstallTitle : ResolvableString.Resource() { + private const val serialVersionUID = 7815666924791958742L + override fun stringId() = R.string.ackpine_prompt_install_title +} + +private object AckpinePromptInstallMessage : ResolvableString.Resource() { + private const val serialVersionUID = 1224637050663404482L + override fun stringId() = R.string.ackpine_prompt_install_message +} + +private class AckpinePromptInstallMessageWithLabel(name: String) : ResolvableString.Resource(name) { + + override fun stringId() = R.string.ackpine_prompt_install_message_with_label + + private companion object { + private const val serialVersionUID = -6931607904159775056L } } \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/PackageInstallerImpl.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/PackageInstallerImpl.kt index 808ffd2e9..4c5721f14 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/PackageInstallerImpl.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/PackageInstallerImpl.kt @@ -202,7 +202,7 @@ internal class PackageInstallerImpl internal constructor( parameters: InstallParameters, dbWriteSemaphore: BinarySemaphore, notificationId: Int - ) { + ) = executor.executeWithSemaphore(dbWriteSemaphore) { var packageName: String? = null val installMode = when (parameters.installMode) { is InstallMode.Full -> InstallModeEntity.InstallMode.FULL @@ -211,27 +211,29 @@ internal class PackageInstallerImpl internal constructor( InstallModeEntity.InstallMode.INHERIT_EXISTING } } - executor.executeWithSemaphore(dbWriteSemaphore) { - installSessionDao.insertInstallSession( - SessionEntity.InstallSession( - session = SessionEntity( - id.toString(), - SessionEntity.Type.INSTALL, - SessionEntity.State.PENDING, - parameters.confirmation, - parameters.notificationData.title, - parameters.notificationData.contentText, - parameters.notificationData.icon, - parameters.requireUserAction - ), - installerType = parameters.installerType, - uris = parameters.apks.toList().map { it.toString() }, - name = parameters.name, - notificationId, installMode, packageName, - lastUpdateTimestamp = Long.MAX_VALUE - ) + val notificationData = installSessionFactory.resolveNotificationData( + parameters.notificationData, + parameters.name + ) + installSessionDao.insertInstallSession( + SessionEntity.InstallSession( + session = SessionEntity( + id.toString(), + SessionEntity.Type.INSTALL, + SessionEntity.State.PENDING, + parameters.confirmation, + notificationData.title, + notificationData.contentText, + notificationData.icon, + parameters.requireUserAction + ), + installerType = parameters.installerType, + uris = parameters.apks.toList().map { it.toString() }, + name = parameters.name, + notificationId, installMode, packageName, + lastUpdateTimestamp = Long.MAX_VALUE ) - } + ) } @SuppressLint("NewApi") diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallActivity.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallActivity.kt index 66473a5b3..270b248d7 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallActivity.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/activity/SessionBasedInstallActivity.kt @@ -28,6 +28,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.RestrictTo import ru.solrudev.ackpine.impl.installer.activity.helpers.getParcelableCompat import ru.solrudev.ackpine.impl.session.helpers.commitSession +import ru.solrudev.ackpine.impl.session.helpers.getSessionBasedSessionCommitProgressValue import ru.solrudev.ackpine.installer.InstallFailure import ru.solrudev.ackpine.session.Session @@ -82,10 +83,10 @@ internal class SessionBasedInstallConfirmationActivity : InstallActivity(CONFIRM return } val sessionInfo = packageInstaller.getSessionInfo(sessionId) - // Hacky workaround: progress not going higher than 0.8 means session is dead. This is needed to complete + // Hacky workaround: progress not going higher after commit means session is dead. This is needed to complete // the Ackpine session with failure on reasons which are not handled in PackageInstallerStatusReceiver. // For example, "There was a problem parsing the package" error falls under that. - val isSessionAlive = sessionInfo != null && sessionInfo.progress >= 0.81 + val isSessionAlive = sessionInfo != null && sessionInfo.progress >= getSessionBasedSessionCommitProgressValue() if (!isSessionAlive) { setLoading(isLoading = true, delayMillis = 100) handler.postDelayed(deadSessionCompletionRunnable, 1000) diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/IntentBasedInstallSession.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/IntentBasedInstallSession.kt index 3559867d9..cbb1eba41 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/IntentBasedInstallSession.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/IntentBasedInstallSession.kt @@ -27,14 +27,13 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import ru.solrudev.ackpine.AckpineFileProvider import ru.solrudev.ackpine.helpers.concurrent.BinarySemaphore -import ru.solrudev.ackpine.helpers.concurrent.executeWithSemaphore import ru.solrudev.ackpine.helpers.concurrent.withPermit import ru.solrudev.ackpine.impl.database.dao.LastUpdateTimestampDao import ru.solrudev.ackpine.impl.database.dao.SessionDao import ru.solrudev.ackpine.impl.database.dao.SessionFailureDao import ru.solrudev.ackpine.impl.database.dao.SessionProgressDao import ru.solrudev.ackpine.impl.installer.activity.IntentBasedInstallActivity -import ru.solrudev.ackpine.impl.installer.session.helpers.STREAM_COPY_PROGRESS_MAX +import ru.solrudev.ackpine.impl.installer.session.helpers.PROGRESS_MAX import ru.solrudev.ackpine.impl.installer.session.helpers.copyTo import ru.solrudev.ackpine.impl.installer.session.helpers.openAssetFileDescriptor import ru.solrudev.ackpine.impl.session.AbstractProgressSession @@ -98,17 +97,25 @@ internal class IntentBasedInstallSession internal constructor( if (initialState.isTerminal) { return } - val isSuccessfulSelfUpdate = if (initialState is Committed && context.packageName == packageName) { - getLastSelfUpdateTimestamp() > lastUpdateTimestamp - } else { - false - } + // Though it somewhat helps with self-update sessions, it's still faulty: + // if app is force-stopped while the session is committed (not confirmed) and in the meantime + // another installer updates the app, this session will be viewed as completed successfully. + // We can check that initiating installer package is the same as ours, but then if this session + // was successful, and before launching the app again it was updated by another installer, + // the session will be stuck as committed. Sadly, without centralized system + // sessions repository, such as android.content.pm.PackageInstaller, we can't reliably determine + // whether the intent-based Ackpine session was really successful. + val isSelfUpdate = initialState is Committed && context.packageName == packageName + val isLastUpdateTimestampUpdated = getLastSelfUpdateTimestamp() > lastUpdateTimestamp + val isSuccessfulSelfUpdate = isSelfUpdate && isLastUpdateTimestampUpdated if (isSuccessfulSelfUpdate && needToCompleteIfSucceeded) { complete(Succeeded) } if (isSuccessfulSelfUpdate) { - executor.executeWithSemaphore(dbWriteSemaphore) { - lastUpdateTimestampDao.setLastUpdateTimestamp(id.toString(), getLastSelfUpdateTimestamp()) + executor.execute { + dbWriteSemaphore.withPermit { + lastUpdateTimestampDao.setLastUpdateTimestamp(id.toString(), getLastSelfUpdateTimestamp()) + } } } } @@ -156,12 +163,12 @@ internal class IntentBasedInstallSession internal constructor( } override fun onCommitted() { - progress = Progress((STREAM_COPY_PROGRESS_MAX * 0.9).roundToInt(), STREAM_COPY_PROGRESS_MAX) + setProgress((PROGRESS_MAX * 0.9).roundToInt()) } override fun onCompleted(success: Boolean) { if (success) { - progress = Progress(STREAM_COPY_PROGRESS_MAX, STREAM_COPY_PROGRESS_MAX) + setProgress(PROGRESS_MAX) } } @@ -187,7 +194,7 @@ internal class IntentBasedInstallSession internal constructor( var currentProgress = 0 apkStream.copyTo(bufferedOutputStream, afd.declaredLength, cancellationSignal, onProgress = { delta -> currentProgress += delta - progress = Progress((currentProgress * 0.8).roundToInt(), STREAM_COPY_PROGRESS_MAX) + setProgress((currentProgress * 0.8).roundToInt()) }) bufferedOutputStream.flush() outputStream.fd.sync() diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/SessionBasedInstallSession.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/SessionBasedInstallSession.kt index c1d914e71..6fff224a5 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/SessionBasedInstallSession.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/SessionBasedInstallSession.kt @@ -42,12 +42,13 @@ import ru.solrudev.ackpine.impl.database.dao.SessionDao import ru.solrudev.ackpine.impl.database.dao.SessionFailureDao import ru.solrudev.ackpine.impl.database.dao.SessionProgressDao import ru.solrudev.ackpine.impl.installer.activity.SessionBasedInstallCommitActivity -import ru.solrudev.ackpine.impl.installer.session.helpers.STREAM_COPY_PROGRESS_MAX +import ru.solrudev.ackpine.impl.installer.session.helpers.PROGRESS_MAX import ru.solrudev.ackpine.impl.installer.session.helpers.copyTo import ru.solrudev.ackpine.impl.installer.session.helpers.openAssetFileDescriptor import ru.solrudev.ackpine.impl.session.AbstractProgressSession import ru.solrudev.ackpine.impl.session.helpers.CANCEL_CURRENT_FLAGS import ru.solrudev.ackpine.impl.session.helpers.commitSession +import ru.solrudev.ackpine.impl.session.helpers.getSessionBasedSessionCommitProgressValue import ru.solrudev.ackpine.impl.session.helpers.launchConfirmation import ru.solrudev.ackpine.installer.InstallFailure import ru.solrudev.ackpine.installer.parameters.InstallMode @@ -107,7 +108,8 @@ internal class SessionBasedInstallSession internal constructor( if (initialState.isTerminal) { return } - if (initialProgress.progress >= 81) { // means that actual installation is ongoing or is completed + if (initialProgress.progress >= (context.getSessionBasedSessionCommitProgressValue() * PROGRESS_MAX).toInt()) { + // means that actual installation is ongoing or is completed notifyCommitted() // block clients from committing } executor.executeWithSemaphore(nativeSessionIdSemaphore) { @@ -209,7 +211,7 @@ internal class SessionBasedInstallSession internal constructor( val future = ResolvableFuture.create() val countdown = AtomicInteger(apks.size) val currentProgress = AtomicInteger(0) - val progressMax = apks.size * STREAM_COPY_PROGRESS_MAX + val progressMax = apks.size * PROGRESS_MAX apks.forEachIndexed { index, uri -> val afd = context.openAssetFileDescriptor(uri, cancellationSignal) ?: error("AssetFileDescriptor was null: $uri") @@ -273,14 +275,14 @@ internal class SessionBasedInstallSession internal constructor( } private fun packageInstallerSessionCallback(nativeSessionId: Int) = object : PackageInstaller.SessionCallback() { - override fun onCreated(sessionId: Int) {} - override fun onBadgingChanged(sessionId: Int) {} - override fun onActiveChanged(sessionId: Int, active: Boolean) {} - override fun onFinished(sessionId: Int, success: Boolean) {} + override fun onCreated(sessionId: Int) { /* no-op */ } + override fun onBadgingChanged(sessionId: Int) { /* no-op */ } + override fun onActiveChanged(sessionId: Int, active: Boolean) { /* no-op */ } + override fun onFinished(sessionId: Int, success: Boolean) { /* no-op */ } override fun onProgressChanged(sessionId: Int, progress: Float) { if (sessionId == nativeSessionId) { - this@SessionBasedInstallSession.progress = Progress((progress * 100).toInt(), 100) + setProgress((progress * PROGRESS_MAX).toInt()) } } } @@ -288,7 +290,7 @@ internal class SessionBasedInstallSession internal constructor( private fun abandonSession() { try { packageInstaller.abandonSession(nativeSessionId) - } catch (_: Throwable) { + } catch (_: Throwable) { // no-op } } diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/helpers/IoHelpers.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/helpers/IoHelpers.kt index 73b3bf88a..d658e738c 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/helpers/IoHelpers.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/helpers/IoHelpers.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,7 @@ import kotlin.math.roundToInt private const val BUFFER_LENGTH = 8192 @get:JvmSynthetic -internal const val STREAM_COPY_PROGRESS_MAX: Int = 100 +internal const val PROGRESS_MAX: Int = 100 @JvmSynthetic internal inline fun InputStream.copyTo( @@ -33,7 +33,7 @@ internal inline fun InputStream.copyTo( signal: CancellationSignal, onProgress: (Int) -> Unit = {} ) { - val progressRatio = (size.toDouble() / (BUFFER_LENGTH * STREAM_COPY_PROGRESS_MAX)).roundToInt().coerceAtLeast(1) + val progressRatio = (size.toDouble() / (BUFFER_LENGTH * PROGRESS_MAX)).roundToInt().coerceAtLeast(1) val buffer = ByteArray(BUFFER_LENGTH) var currentProgress = 0 var accumulatedBytesRead = 0 @@ -50,11 +50,11 @@ internal inline fun InputStream.copyTo( accumulatedBytesRead = 0 val progress = ++currentProgress / progressRatio val shouldEmitProgress = currentProgress - (progress * progressRatio) == 0 - if (shouldEmitProgress && progress <= STREAM_COPY_PROGRESS_MAX) { + if (shouldEmitProgress && progress <= PROGRESS_MAX) { progressEmitCounter++ onProgress(1) } } } - onProgress(STREAM_COPY_PROGRESS_MAX - progressEmitCounter) + onProgress(PROGRESS_MAX - progressEmitCounter) } \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractProgressSession.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractProgressSession.kt index f62439745..9e3a26379 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractProgressSession.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractProgressSession.kt @@ -26,6 +26,7 @@ import ru.solrudev.ackpine.helpers.concurrent.BinarySemaphore import ru.solrudev.ackpine.impl.database.dao.SessionDao import ru.solrudev.ackpine.impl.database.dao.SessionFailureDao import ru.solrudev.ackpine.impl.database.dao.SessionProgressDao +import ru.solrudev.ackpine.impl.installer.session.helpers.PROGRESS_MAX import ru.solrudev.ackpine.session.Failure import ru.solrudev.ackpine.session.Progress import ru.solrudev.ackpine.session.ProgressSession @@ -64,7 +65,7 @@ internal abstract class AbstractProgressSession protected construct ) @Volatile - protected var progress = initialProgress + private var progress = initialProgress set(value) { if (field == value) { return @@ -98,6 +99,10 @@ internal abstract class AbstractProgressSession protected construct progressListeners -= listener } + protected fun setProgress(value: Int) { + progress = Progress(value, PROGRESS_MAX) + } + private fun persistSessionProgress(value: Progress) = executor.execute { sessionProgressDao.updateProgress(id.toString(), value.progress, value.max) } diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractSession.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractSession.kt index 85be5e503..663f6089d 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractSession.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractSession.kt @@ -119,7 +119,7 @@ internal abstract class AbstractSession protected constructor( * Release any held resources after session's completion or cancellation. Processing in this method should be * lightweight. */ - protected open fun doCleanup() {} + protected open fun doCleanup() { /* optional */ } /** * Notifies that preparations are done and sets session's state to [Awaiting]. @@ -133,13 +133,13 @@ internal abstract class AbstractSession protected constructor( * This callback method is invoked when the session's been committed. Processing in this method should be * lightweight. */ - protected open fun onCommitted() {} + protected open fun onCommitted() { /* optional */ } /** * This callback method is invoked when the session's been [completed][Session.isCompleted]. Processing in * this method should be lightweight. */ - protected open fun onCompleted(success: Boolean) {} + protected open fun onCompleted(success: Boolean) { /* optional */ } final override fun launch(): Boolean { if (isPreparing || isCancelling.get()) { diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/helpers/LaunchConfirmation.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/helpers/LaunchConfirmation.kt index bd6656b66..136891f73 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/helpers/LaunchConfirmation.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/helpers/LaunchConfirmation.kt @@ -19,11 +19,13 @@ package ru.solrudev.ackpine.impl.session.helpers import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.pm.PackageInstaller import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.content.edit import androidx.core.content.getSystemService import ru.solrudev.ackpine.core.R import ru.solrudev.ackpine.impl.activity.SessionCommitActivity @@ -32,6 +34,9 @@ import ru.solrudev.ackpine.session.parameters.Confirmation import ru.solrudev.ackpine.session.parameters.NotificationData import java.util.UUID +private const val ACKPINE_SESSION_BASED_INSTALLER = "ackpine_session_based_installer" +private const val SESSION_COMMIT_PROGRESS_VALUE = "session_commit_progress_value" + @get:JvmSynthetic internal val CANCEL_CURRENT_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT @@ -87,9 +92,21 @@ internal fun PackageInstaller.commitSession( val statusReceiver = receiverPendingIntent.intentSender if (getSessionInfo(sessionId) != null) { openSession(sessionId).commit(statusReceiver) + val preferences = context.getSharedPreferences(ACKPINE_SESSION_BASED_INSTALLER, MODE_PRIVATE) + if (!preferences.contains(SESSION_COMMIT_PROGRESS_VALUE)) { + preferences.edit { + putFloat(SESSION_COMMIT_PROGRESS_VALUE, getSessionInfo(sessionId)!!.progress + 0.01f) + } + } } } +@JvmSynthetic +internal fun Context.getSessionBasedSessionCommitProgressValue(): Float { + return getSharedPreferences(ACKPINE_SESSION_BASED_INSTALLER, MODE_PRIVATE) + .getFloat(SESSION_COMMIT_PROGRESS_VALUE, 1f) +} + private fun Context.showNotification( intent: PendingIntent, notificationData: NotificationData, @@ -109,7 +126,7 @@ private fun Context.showNotification( setContentIntent(intent) priority = NotificationCompat.PRIORITY_MAX setDefaults(NotificationCompat.DEFAULT_ALL) - setSmallIcon(notificationData.icon) + setSmallIcon(notificationData.icon.drawableId()) setOngoing(true) setAutoCancel(true) }.build() diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/PackageUninstallerImpl.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/PackageUninstallerImpl.kt index 9b560a676..f082cf49f 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/PackageUninstallerImpl.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/PackageUninstallerImpl.kt @@ -139,6 +139,10 @@ internal class PackageUninstallerImpl internal constructor( dbWriteSemaphore: BinarySemaphore, notificationId: Int ) = executor.executeWithSemaphore(dbWriteSemaphore) { + val notificationData = uninstallSessionFactory.resolveNotificationData( + parameters.notificationData, + parameters.packageName + ) uninstallSessionDao.insertUninstallSession( SessionEntity.UninstallSession( session = SessionEntity( @@ -146,9 +150,9 @@ internal class PackageUninstallerImpl internal constructor( SessionEntity.Type.UNINSTALL, SessionEntity.State.PENDING, parameters.confirmation, - parameters.notificationData.title, - parameters.notificationData.contentText, - parameters.notificationData.icon, + notificationData.title, + notificationData.contentText, + notificationData.icon, requireUserAction = true ), packageName = parameters.packageName, diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/UninstallSessionFactory.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/UninstallSessionFactory.kt index bb97b27b5..e93e1b8fd 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/UninstallSessionFactory.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/uninstaller/UninstallSessionFactory.kt @@ -25,9 +25,10 @@ import ru.solrudev.ackpine.impl.database.dao.SessionDao import ru.solrudev.ackpine.impl.database.dao.SessionFailureDao import ru.solrudev.ackpine.impl.uninstaller.helpers.getApplicationLabel import ru.solrudev.ackpine.impl.uninstaller.session.UninstallSession +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.session.Session +import ru.solrudev.ackpine.session.parameters.DEFAULT_NOTIFICATION_STRING import ru.solrudev.ackpine.session.parameters.NotificationData -import ru.solrudev.ackpine.session.parameters.NotificationString import ru.solrudev.ackpine.uninstaller.UninstallFailure import ru.solrudev.ackpine.uninstaller.parameters.UninstallParameters import java.util.UUID @@ -43,6 +44,8 @@ internal interface UninstallSessionFactory { notificationId: Int, dbWriteSemaphore: BinarySemaphore ): Session + + fun resolveNotificationData(notificationData: NotificationData, packageName: String): NotificationData } @RestrictTo(RestrictTo.Scope.LIBRARY) @@ -66,27 +69,51 @@ internal class UninstallSessionFactoryImpl internal constructor( parameters.packageName, id, initialState, parameters.confirmation, - parameters.notificationData.resolveDefault(parameters.packageName), + resolveNotificationData(parameters.notificationData, parameters.packageName), sessionDao, sessionFailureDao, executor, handler, notificationId, dbWriteSemaphore ) } - private fun NotificationData.resolveDefault(packageName: String): NotificationData = NotificationData.Builder() - .setTitle( - title.takeUnless { it.isDefault } ?: NotificationString.resource(R.string.ackpine_prompt_uninstall_title) - ) - .setContentText( - contentText.takeUnless { it.isDefault } ?: resolveDefaultContentText(packageName) - ) - .setIcon(icon) - .build() + override fun resolveNotificationData( + notificationData: NotificationData, + packageName: String + ) = notificationData.run { + NotificationData.Builder() + .setTitle( + title.takeUnless { it === DEFAULT_NOTIFICATION_STRING } ?: AckpinePromptUninstallTitle + ) + .setContentText( + contentText.takeUnless { it === DEFAULT_NOTIFICATION_STRING } ?: resolveDefaultContentText(packageName) + ) + .setIcon(icon) + .build() + } - private fun resolveDefaultContentText(packageName: String): NotificationString { + private fun resolveDefaultContentText(packageName: String): ResolvableString { val label = applicationContext.packageManager.getApplicationLabel(packageName)?.toString() if (label != null) { - return NotificationString.resource(R.string.ackpine_prompt_uninstall_message_with_label, label) + return AckpinePromptUninstallMessageWithLabel(label) } - return NotificationString.resource(R.string.ackpine_prompt_uninstall_message) + return AckpinePromptUninstallMessage + } +} + +private object AckpinePromptUninstallTitle : ResolvableString.Resource() { + private const val serialVersionUID = -4086992997791586590L + override fun stringId() = R.string.ackpine_prompt_uninstall_title +} + +private object AckpinePromptUninstallMessage : ResolvableString.Resource() { + private const val serialVersionUID = -3150252606151986307L + override fun stringId(): Int = R.string.ackpine_prompt_uninstall_message +} + +private class AckpinePromptUninstallMessageWithLabel(label: String) : ResolvableString.Resource(label) { + + override fun stringId() = R.string.ackpine_prompt_uninstall_message_with_label + + private companion object { + private const val serialVersionUID = 5259262335605612228L } } \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/installer/parameters/InstallParameters.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/installer/parameters/InstallParameters.kt index dcb40d534..609dd09c2 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/installer/parameters/InstallParameters.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/installer/parameters/InstallParameters.kt @@ -325,7 +325,14 @@ private class RealMutableApkList : MutableApkList { } override fun toList() = apks.toList() - override fun equals(other: Any?) = apks == other + + override fun equals(other: Any?): Boolean { + if (other !is RealMutableApkList) { + return false + } + return apks == other + } + override fun hashCode() = apks.hashCode() override fun toString() = "ApkList($apks)" diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/Session.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/Session.kt index eabd01386..6b1246c83 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/Session.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/Session.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -208,20 +208,20 @@ public interface Session { * Notifies that session was completed successfully. * @param sessionId ID of the session which had its state updated. */ - public open fun onSuccess(sessionId: UUID) {} + public open fun onSuccess(sessionId: UUID) { /* to be overridden */ } /** * Notifies that session was completed with an error. * @param sessionId ID of the session which had its state updated. * @param failure session's failure cause. */ - public open fun onFailure(sessionId: UUID, failure: F) {} + public open fun onFailure(sessionId: UUID, failure: F) { /* to be overridden */ } /** * Notifies that session was cancelled. * @param sessionId ID of the session which had its state updated. */ - public open fun onCancelled(sessionId: UUID) {} + public open fun onCancelled(sessionId: UUID) { /* to be overridden */ } final override fun onStateChanged(sessionId: UUID, state: State) { if (state.isTerminal) { diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationData.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationData.kt index 413d87d2d..a0565121b 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationData.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationData.kt @@ -14,9 +14,15 @@ * limitations under the License. */ +@file:JvmName("NotificationDataConstants") + package ru.solrudev.ackpine.session.parameters +import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes +import androidx.annotation.RestrictTo +import ru.solrudev.ackpine.resources.ResolvableString +import java.io.Serializable /** * Data for a high-priority notification which launches confirmation activity. @@ -28,17 +34,17 @@ public class NotificationData private constructor( * * Default value is [android.R.drawable.ic_dialog_alert]. */ - @DrawableRes public val icon: Int, + public val icon: DrawableId, /** * Notification title. */ - public val title: NotificationString, + public val title: ResolvableString, /** * Notification text. */ - public val contentText: NotificationString + public val contentText: ResolvableString ) { override fun toString(): String { @@ -49,16 +55,16 @@ public class NotificationData private constructor( if (this === other) return true if (javaClass != other?.javaClass) return false other as NotificationData + if (icon != other.icon) return false if (title != other.title) return false if (contentText != other.contentText) return false - if (icon != other.icon) return false return true } override fun hashCode(): Int { - var result = title.hashCode() + var result = icon.hashCode() + result = 31 * result + title.hashCode() result = 31 * result + contentText.hashCode() - result = 31 * result + icon return result } @@ -72,9 +78,9 @@ public class NotificationData private constructor( */ @JvmField public val DEFAULT: NotificationData = NotificationData( - icon = android.R.drawable.ic_dialog_alert, - title = NotificationString.default(), - contentText = NotificationString.default() + icon = DefaultNotificationIcon, + title = DEFAULT_NOTIFICATION_STRING, + contentText = DEFAULT_NOTIFICATION_STRING ) } @@ -88,8 +94,7 @@ public class NotificationData private constructor( * * Default value is [android.R.drawable.ic_dialog_alert]. */ - @DrawableRes - public var icon: Int = DEFAULT.icon + public var icon: DrawableId = DEFAULT.icon private set /** @@ -97,7 +102,7 @@ public class NotificationData private constructor( * * By default, a string from Ackpine library is used. */ - public var title: NotificationString = DEFAULT.title + public var title: ResolvableString = DEFAULT.title private set /** @@ -105,27 +110,27 @@ public class NotificationData private constructor( * * By default, a string from Ackpine library is used. */ - public var contentText: NotificationString = DEFAULT.contentText + public var contentText: ResolvableString = DEFAULT.contentText private set /** * Sets [NotificationData.icon]. */ - public fun setIcon(@DrawableRes icon: Int): Builder = apply { + public fun setIcon(icon: DrawableId): Builder = apply { this.icon = icon } /** * Sets [NotificationData.title]. */ - public fun setTitle(title: NotificationString): Builder = apply { + public fun setTitle(title: ResolvableString): Builder = apply { this.title = title } /** * Sets [NotificationData.contentText]. */ - public fun setContentText(contentText: NotificationString): Builder = apply { + public fun setContentText(contentText: ResolvableString): Builder = apply { this.contentText = contentText } @@ -134,4 +139,37 @@ public class NotificationData private constructor( */ public fun build(): NotificationData = NotificationData(icon, title, contentText) } -} \ No newline at end of file +} + +/** + * [Drawable] represented by Android resource ID. + * + * Should be explicitly subclassed to ensure stable persistence, and `serialVersionUID` must be present. Example: + * ``` + * object InstallIcon : DrawableId { + * private const val serialVersionUID = 3692803605642002954L + * override fun drawableId() = R.drawable.ic_install + * } + * ``` + */ +public interface DrawableId : Serializable { + + /** + * Returns an Android drawable resource ID. + */ + @DrawableRes + public fun drawableId(): Int + + private companion object { + private const val serialVersionUID = 6564416758029834576L + } +} + +private object DefaultNotificationIcon : DrawableId { + private const val serialVersionUID = 6906923061913799903L + override fun drawableId() = android.R.drawable.ic_dialog_alert +} + +@RestrictTo(RestrictTo.Scope.LIBRARY) +@get:JvmSynthetic +internal val DEFAULT_NOTIFICATION_STRING = ResolvableString.raw("ACKPINE_DEFAULT_NOTIFICATION_STRING") \ No newline at end of file diff --git a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationString.kt b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationString.kt index da3d2026a..c775f21c6 100644 --- a/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationString.kt +++ b/ackpine-core/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationString.kt @@ -25,29 +25,57 @@ import java.io.Serializable /** * String for a session's [confirmation notification][NotificationData]. */ +@Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR +) public sealed interface NotificationString : Serializable { /** * Returns whether this string represents a default string. */ + @Deprecated(message = "This property is removed from ResolvableString.", level = DeprecationLevel.ERROR) public val isDefault: Boolean get() = this is Default /** * Returns whether this string is empty. */ + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith(expression = "ResolvableString.isEmpty") + ) public val isEmpty: Boolean get() = this is Empty /** * Returns whether this string represents a hardcoded string. */ + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith(expression = "ResolvableString.isRaw") + ) public val isRaw: Boolean get() = this is Raw /** * Returns whether this string represents a resource string. */ + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith(expression = "ResolvableString.isResource") + ) public val isResource: Boolean get() = this is Resource @@ -56,24 +84,59 @@ public sealed interface NotificationString : Serializable { */ public fun resolve(context: Context): String + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR + ) public companion object { /** * Creates a default [NotificationString]. */ @JvmStatic + @Suppress("DEPRECATION_ERROR") + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR + ) public fun default(): NotificationString = Default /** * Creates an empty [NotificationString]. */ @JvmStatic + @Suppress("DEPRECATION_ERROR") + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith( + expression = "ResolvableString.empty()", + imports = ["ru.solrudev.ackpine.resources.ResolvableString"] + ) + ) public fun empty(): NotificationString = Empty /** * Creates [NotificationString] with a hardcoded value. */ @JvmStatic + @Suppress("DEPRECATION_ERROR") + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Use ResolvableString to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith( + expression = "ResolvableString.raw(value)", + imports = ["ru.solrudev.ackpine.resources.ResolvableString"] + ) + ) public fun raw(value: String): NotificationString { if (value.isEmpty()) { return Empty @@ -86,29 +149,48 @@ public sealed interface NotificationString : Serializable { * [NotificationStrings][NotificationString] as well. */ @JvmStatic + @Suppress("DEPRECATION_ERROR") + @Deprecated( + message = "This class cannot provide persistence stability of string resources in case of string " + + "resources updates. Subclass ResolvableString.Resource to provide strings to notifications. " + + "NotificationString will be removed in next minor release.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith( + expression = "class StringResource(vararg args: Serializable) : ResolvableString.Resource(*args) { \n" + + "\toverride fun stringId() = stringId\n\tprivate companion object {\n" + + "\t\tprivate const val serialVersionUID = PUT_STRING_RESOURCE_SERIAL_VERSION_UID_HERE\n" + + "\t}\n}", + imports = ["ru.solrudev.ackpine.resources.ResolvableString"] + ) + ) public fun resource(@StringRes stringId: Int, vararg args: Serializable): NotificationString { return Resource(stringId, args) } } } +@Suppress("DEPRECATION_ERROR") private data object Default : NotificationString { private const val serialVersionUID = 809543744617543082L override fun resolve(context: Context): String = "" } +@Suppress("DEPRECATION_ERROR") private data object Empty : NotificationString { private const val serialVersionUID: Long = 5194188194930148316L override fun resolve(context: Context): String = "" } +@Suppress("DEPRECATION_ERROR") private data class Raw(val value: String) : NotificationString { override fun resolve(context: Context): String = value + private companion object { private const val serialVersionUID: Long = -6824736411987160679L } } +@Suppress("DEPRECATION_ERROR") private data class Resource(@StringRes val stringId: Int, val args: Array) : NotificationString { override fun resolve(context: Context): String = context.getString(stringId, *resolveArgs(context)) diff --git a/ackpine-ktx/api/ackpine-ktx.api b/ackpine-ktx/api/ackpine-ktx.api index 6fa53486d..28a8c148c 100644 --- a/ackpine-ktx/api/ackpine-ktx.api +++ b/ackpine-ktx/api/ackpine-ktx.api @@ -98,23 +98,23 @@ public final class ru/solrudev/ackpine/session/parameters/ConfirmationDslKt { } public abstract interface class ru/solrudev/ackpine/session/parameters/NotificationDataDsl { - public abstract fun getContentText ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public abstract fun getIcon ()I - public abstract fun getTitle ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public abstract fun setContentText (Lru/solrudev/ackpine/session/parameters/NotificationString;)V - public abstract fun setIcon (I)V - public abstract fun setTitle (Lru/solrudev/ackpine/session/parameters/NotificationString;)V + public abstract fun getContentText ()Lru/solrudev/ackpine/resources/ResolvableString; + public abstract fun getIcon ()Lru/solrudev/ackpine/session/parameters/DrawableId; + public abstract fun getTitle ()Lru/solrudev/ackpine/resources/ResolvableString; + public abstract fun setContentText (Lru/solrudev/ackpine/resources/ResolvableString;)V + public abstract fun setIcon (Lru/solrudev/ackpine/session/parameters/DrawableId;)V + public abstract fun setTitle (Lru/solrudev/ackpine/resources/ResolvableString;)V } public final class ru/solrudev/ackpine/session/parameters/NotificationDataDslBuilder : ru/solrudev/ackpine/session/parameters/NotificationDataDsl { public fun ()V public final fun build ()Lru/solrudev/ackpine/session/parameters/NotificationData; - public fun getContentText ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public fun getIcon ()I - public fun getTitle ()Lru/solrudev/ackpine/session/parameters/NotificationString; - public fun setContentText (Lru/solrudev/ackpine/session/parameters/NotificationString;)V - public fun setIcon (I)V - public fun setTitle (Lru/solrudev/ackpine/session/parameters/NotificationString;)V + public fun getContentText ()Lru/solrudev/ackpine/resources/ResolvableString; + public fun getIcon ()Lru/solrudev/ackpine/session/parameters/DrawableId; + public fun getTitle ()Lru/solrudev/ackpine/resources/ResolvableString; + public fun setContentText (Lru/solrudev/ackpine/resources/ResolvableString;)V + public fun setIcon (Lru/solrudev/ackpine/session/parameters/DrawableId;)V + public fun setTitle (Lru/solrudev/ackpine/resources/ResolvableString;)V } public final class ru/solrudev/ackpine/session/parameters/NotificationDataKt { diff --git a/ackpine-ktx/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationDataDsl.kt b/ackpine-ktx/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationDataDsl.kt index 969894995..c6cd7ccc7 100644 --- a/ackpine-ktx/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationDataDsl.kt +++ b/ackpine-ktx/src/main/kotlin/ru/solrudev/ackpine/session/parameters/NotificationDataDsl.kt @@ -16,8 +16,7 @@ package ru.solrudev.ackpine.session.parameters -import android.annotation.SuppressLint -import androidx.annotation.DrawableRes +import ru.solrudev.ackpine.resources.ResolvableString /** * DSL allowing to configure [high-priority notification for Session confirmation][NotificationData]. @@ -30,24 +29,21 @@ public interface NotificationDataDsl { * * Default value is [android.R.drawable.ic_dialog_alert]. */ - @set:SuppressLint("SupportAnnotationUsage") - @get:DrawableRes - @set:DrawableRes - public var icon: Int + public var icon: DrawableId /** * Notification title. * * By default, a string from Ackpine library is used. */ - public var title: NotificationString + public var title: ResolvableString /** * Notification text. * * By default, a string from Ackpine library is used. */ - public var contentText: NotificationString + public var contentText: ResolvableString } @PublishedApi @@ -55,19 +51,19 @@ internal class NotificationDataDslBuilder : NotificationDataDsl { private val builder = NotificationData.Builder() - override var icon: Int + override var icon: DrawableId get() = builder.icon set(value) { builder.setIcon(value) } - override var title: NotificationString + override var title: ResolvableString get() = builder.title set(value) { builder.setTitle(value) } - override var contentText: NotificationString + override var contentText: ResolvableString get() = builder.contentText set(value) { builder.setContentText(value) diff --git a/ackpine-resources/api/ackpine-resources.api b/ackpine-resources/api/ackpine-resources.api new file mode 100644 index 000000000..47b092833 --- /dev/null +++ b/ackpine-resources/api/ackpine-resources.api @@ -0,0 +1,25 @@ +public abstract interface class ru/solrudev/ackpine/resources/ResolvableString : java/io/Serializable { + public static final field Companion Lru/solrudev/ackpine/resources/ResolvableString$Companion; + public static fun empty ()Lru/solrudev/ackpine/resources/ResolvableString; + public fun isEmpty ()Z + public fun isRaw ()Z + public fun isResource ()Z + public static fun raw (Ljava/lang/String;)Lru/solrudev/ackpine/resources/ResolvableString; + public abstract fun resolve (Landroid/content/Context;)Ljava/lang/String; + public static fun transientResource (I[Ljava/io/Serializable;)Lru/solrudev/ackpine/resources/ResolvableString; +} + +public final class ru/solrudev/ackpine/resources/ResolvableString$Companion { + public final fun empty ()Lru/solrudev/ackpine/resources/ResolvableString; + public final fun raw (Ljava/lang/String;)Lru/solrudev/ackpine/resources/ResolvableString; + public final fun transientResource (I[Ljava/io/Serializable;)Lru/solrudev/ackpine/resources/ResolvableString; +} + +public abstract class ru/solrudev/ackpine/resources/ResolvableString$Resource : ru/solrudev/ackpine/resources/ResolvableString { + public fun ([Ljava/io/Serializable;)V + public final fun equals (Ljava/lang/Object;)Z + public final fun hashCode ()I + public final fun resolve (Landroid/content/Context;)Ljava/lang/String; + protected abstract fun stringId ()I +} + diff --git a/ackpine-resources/build.gradle.kts b/ackpine-resources/build.gradle.kts new file mode 100644 index 000000000..34663e124 --- /dev/null +++ b/ackpine-resources/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 Ilya Fomichev + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +description = "Abstractions for resolvable and persistable Android resources" + +plugins { + id("ru.solrudev.ackpine.library") + id("ru.solrudev.ackpine.library-publish") +} + +ackpine { + id = "resources" + artifact { + name = "Ackpine Resources" + } +} + +dependencies { + api(androidx.annotation) +} \ No newline at end of file diff --git a/ackpine-resources/consumer-rules.pro b/ackpine-resources/consumer-rules.pro new file mode 100644 index 000000000..b6eca166f --- /dev/null +++ b/ackpine-resources/consumer-rules.pro @@ -0,0 +1,6 @@ +# Serializable +-keep class ru.solrudev.ackpine.resources.ResolvableString { *; } +-keep class ru.solrudev.ackpine.resources.ResolvableString$* { *; } +-keep class * extends ru.solrudev.ackpine.resources.ResolvableString$Resource { *; } +-keep class ru.solrudev.ackpine.resources.Empty { *; } +-keep class ru.solrudev.ackpine.resources.Raw { *; } \ No newline at end of file diff --git a/ackpine-resources/proguard-rules.pro b/ackpine-resources/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/ackpine-resources/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ackpine-resources/src/main/AndroidManifest.xml b/ackpine-resources/src/main/AndroidManifest.xml new file mode 100644 index 000000000..2e0de6087 --- /dev/null +++ b/ackpine-resources/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt b/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt new file mode 100644 index 000000000..6dd932b4e --- /dev/null +++ b/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2023-2024 Ilya Fomichev + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("Unused") + +package ru.solrudev.ackpine.resources + +import android.content.Context +import androidx.annotation.StringRes +import java.io.Serializable + +/** + * String which can be resolved at use site. + */ +public sealed interface ResolvableString : Serializable { + + /** + * Returns whether this string is empty. + */ + public val isEmpty: Boolean + get() = this is Empty + + /** + * Returns whether this string represents a hardcoded string. + */ + public val isRaw: Boolean + get() = this is Raw + + /** + * Returns whether this string represents a resource string. + */ + public val isResource: Boolean + get() = this is Resource + + /** + * Resolves string value for a given [context]. + */ + public fun resolve(context: Context): String + + public companion object { + + /** + * Returns an empty [ResolvableString]. + */ + @JvmStatic + public fun empty(): ResolvableString = Empty + + /** + * Creates [ResolvableString] with a hardcoded value. + */ + @JvmStatic + public fun raw(value: String): ResolvableString { + if (value.isEmpty()) { + return Empty + } + return Raw(value) + } + + /** + * Creates an anonymous instance of [ResolvableString.Resource], which is a [ResolvableString] backed by + * Android resource string with optional [arguments][args]. Arguments can be + * [ResolvableStrings][ResolvableString] as well. + * + * This factory is meant to create only **transient** strings, i.e. not persisted in storage. For persisted + * strings [ResolvableString.Resource] should be explicitly subclassed. Example: + * ``` + * object InstallMessageTitle : ResolvableString.Resource() { + * override fun stringId() = R.string.install_message_title + * private const val serialVersionUID = -1310602635578779088L + * } + * + * class InstallMessage(fileName: String) : ResolvableString.Resource(fileName) { + * override fun stringId() = R.string.install_message + * private companion object { + * private const val serialVersionUID = 4749568844072243110L + * } + * } + * ``` + * + * @param stringId Android string resource ID + * @param args string format arguments + */ + @Suppress("serial") + @JvmStatic + public fun transientResource(@StringRes stringId: Int, vararg args: Serializable): ResolvableString { + return object : Resource(*args) { + override fun stringId() = stringId + } + } + } + + /** + * [ResolvableString] backed by Android resource string with optional [arguments][args]. Arguments can be + * [ResolvableStrings][ResolvableString] as well. + * + * Should be explicitly subclassed to ensure stable persistence, and `serialVersionUID` must be present. Example: + * ``` + * object InstallMessageTitle : ResolvableString.Resource() { + * override fun stringId() = R.string.install_message_title + * private const val serialVersionUID = -1310602635578779088L + * } + * + * class InstallMessage(fileName: String) : ResolvableString.Resource(fileName) { + * override fun stringId() = R.string.install_message + * private companion object { + * private const val serialVersionUID = 4749568844072243110L + * } + * } + * ``` + * For transient strings, i.e. not persisted in storage, you can use [ResolvableString.transientResource] factory. + * + * @param args string format arguments + */ + public abstract class Resource(private vararg val args: Serializable) : ResolvableString { + + /** + * Returns an Android string resource ID. + */ + @StringRes + protected abstract fun stringId(): Int + + final override fun resolve(context: Context): String = context.getString(stringId(), *resolveArgs(context)) + + private fun resolveArgs(context: Context): Array = args.map { argument -> + if (argument is ResolvableString) { + argument.resolve(context) + } else { + argument + } + }.toTypedArray() + + final override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Resource + return args.contentEquals(other.args) + } + + final override fun hashCode(): Int { + return args.contentHashCode() + } + + private companion object { + private const val serialVersionUID = -7766769726170724379L + } + } +} + +private data object Empty : ResolvableString { + private const val serialVersionUID = 5194188194930148316L + override fun resolve(context: Context): String = "" +} + +private data class Raw(val value: String) : ResolvableString { + override fun resolve(context: Context): String = value + private companion object { + private const val serialVersionUID = -6824736411987160679L + } +} \ No newline at end of file diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/CloseableSequence.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/CloseableSequence.kt index 23247e77a..cc17458fe 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/CloseableSequence.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/CloseableSequence.kt @@ -88,8 +88,8 @@ private class CloseableSequenceImpl( ) override fun iterator(): Iterator { - if (!isConsumed.compareAndSet(false, true)) { - throw IllegalStateException("This sequence can be consumed only once.") + check(isConsumed.compareAndSet(false, true)) { + "This sequence can be consumed only once." } return iterator { scope = this diff --git a/docs/changelog.md b/docs/changelog.md index f1c014d83..36ab7609e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,47 @@ Change Log ========== +Version 0.8.0 (2024-10-25) +-------------------------- + +### Dependencies + +- Extracted `ackpine-resources` artifact, which is now depended upon by `ackpine-core`. + +### Bug fixes and improvements + +- `NotificationString` is superseded by `ResolvableString` to accommodate stable string resources resolution. `ResolvableString` is now located in `ackpine-resources` artifact and can also be used separately for general app needs. `NotificationString` is deprecated and will be removed in next minor release. + + To migrate `NotificationString.resource()` usages to `ResolvableString`, create classes inheriting from `ResolvableString.Resource` like this: + ```kotlin + // Old + NotificationString.resource(R.string.install_message, fileName) + + // New + class InstallMessage(fileName: String) : ResolvableString.Resource(fileName) { + override fun stringId() = R.string.install_message + private companion object { + private const val serialVersionUID = 4749568844072243110L + } + } + + InstallMessage(fileName) + ``` + + Note that this requires to purge internal database because of incompatible changes, so all previous sessions will be cleared when Ackpine is updated to 0.8.0. + +- `NotificationData` now requires an instance of `DrawableId` class instead of integer drawable resource ID for icon to accommodate stable drawable resources resolution. +- Don't hardcode a condition in implementation of `SESSION_BASED` sessions when Android's `PackageInstaller.Session` fails without report. It should possibly improve reliability on different devices. +- Fix progress bars on install screen not using latest value in sample apps. +- Disable cancel button when session's state is Committed in sample apps. + +### Public API changes + +- Breaking: `NotificationData`, `NotificationData.Builder` and `NotificationDataDsl` now require `ResolvableString` instead of `NotificationString` as `title` and `contentText` type. `NotificationString` is deprecated with an error deprecation level and will be removed in next minor release. +- Breaking: `NotificationData`, `NotificationData.Builder` and `NotificationDataDsl` now require `DrawableId` instead of integer as `icon` type. +- Added `ResolvableString` sealed interface in `ackpine-resources` module. +- Added `DrawableId` interface in `ackpine-core` module. + Version 0.7.6 (2024-10-12) -------------------------- diff --git a/docs/configuration.md b/docs/configuration.md index 897b87cdd..57edb7711 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,11 +21,28 @@ An example of creating a session with custom parameters: name = fileName requireUserAction = false notification { - title = NotificationString.resource(R.string.install_message_title) - contentText = NotificationString.resource(R.string.install_message, fileName) - icon = R.drawable.ic_install + title = InstallMessageTitle + contentText = InstallMessage(fileName) + icon = InstallIcon } } + + object InstallMessageTitle : ResolvableString.Resource() { + private const val serialVersionUID = -1310602635578779088L + override fun stringId() = R.string.install_message_title + } + + class InstallMessage(fileName: String) : ResolvableString.Resource(fileName) { + override fun stringId() = R.string.install_message + private companion object { + private const val serialVersionUID = 4749568844072243110L + } + } + + object InstallIcon : DrawableId { + private const val serialVersionUID = 3692803605642002954L + override fun drawableId() = R.drawable.ic_install + } ``` === "Java" @@ -39,11 +56,54 @@ An example of creating a session with custom parameters: .setName(fileName) .setRequireUserAction(false) .setNotificationData(new NotificationData.Builder() - .setTitle(NotificationString.resource(R.string.install_message_title)) - .setContentText(NotificationString.resource(R.string.install_message, fileName)) - .setIcon(R.drawable.ic_install) + .setTitle(Resources.INSTALL_MESSAGE_TITLE) + .setContentText(new Resources.InstallMessage(fileName)) + .setIcon(Resources.INSTALL_ICON) .build()) .build()); + + public abstract class Resources { + + public static final ResolvableString INSTALL_MESSAGE_TITLE = new InstallMessageTitle(); + public static final DrawableId INSTALL_ICON = new InstallIcon(); + + private static class InstallMessageTitle extends ResolvableString.Resource { + + @Serial + private static final long serialVersionUID = -1310602635578779088L; + + @Override + protected int stringId() { + return R.string.install_message_title; + } + } + + public static class InstallMessage extends ResolvableString.Resource { + + @Serial + private static final long serialVersionUID = 4749568844072243110L; + + public InstallMessage(String fileName) { + super(fileName); + } + + @Override + protected int stringId() { + return R.string.install_message; + } + } + + private static class InstallIcon implements DrawableId { + + @Serial + private static final long serialVersionUID = 3692803605642002954L; + + @Override + public int drawableId() { + return R.drawable.ic_install; + } + } + } ``` User's confirmation @@ -79,7 +139,7 @@ It is possible to provide notification title, text and icon. !!! Note Any configuration for notification will be ignored if `Confirmation` is set to `IMMEDIATE`, because the notification will not be shown. -`NotificationString` is a type used for `NotificationData` text values. It allows to incapsulate an Android string resource (with arguments) which will be resolved only when notification will be shown, a hardcoded string value or a default value from Ackpine library. +`ResolvableString` is a type used for `NotificationData` text values. It allows to incapsulate an Android string resource (with arguments) which will be resolved only when notification will be shown, a hardcoded string value or a default value from Ackpine library if nothing was set. `android.R.drawable.ic_dialog_alert` is used as a default icon. diff --git a/docs/index.md b/docs/index.md index e421d94d7..cec0269b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,7 @@ Ackpine depends on Jetpack libraries, so it's necessary to declare the `google() ```kotlin dependencies { - val ackpineVersion = "0.7.6" + val ackpineVersion = "0.8.0" implementation("ru.solrudev.ackpine:ackpine-core:$ackpineVersion") // optional - Kotlin extensions and Coroutines support diff --git a/sample-java/build.gradle.kts b/sample-java/build.gradle.kts index 6c948d83b..96c103521 100644 --- a/sample-java/build.gradle.kts +++ b/sample-java/build.gradle.kts @@ -64,6 +64,7 @@ android { dependencies { implementation(projects.ackpineCore) implementation(projects.ackpineSplits) + implementation(projects.ackpineResources) implementation(androidx.activity) implementation(androidx.appcompat) implementation(androidx.recyclerview) diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java index dc6fd030e..99334926c 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java @@ -131,7 +131,7 @@ private void onInstallButtonClick() { private void chooseFile() { try { pickerLauncher.launch("*/*"); - } catch (ActivityNotFoundException ignored) { + } catch (ActivityNotFoundException ignored) { // no-op } } diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.java index 1116573af..915cb0082 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.java @@ -38,15 +38,15 @@ import java.util.Objects; import java.util.UUID; +import ru.solrudev.ackpine.resources.ResolvableString; import ru.solrudev.ackpine.sample.R; import ru.solrudev.ackpine.sample.databinding.ItemInstallSessionBinding; import ru.solrudev.ackpine.session.Progress; -import ru.solrudev.ackpine.session.parameters.NotificationString; public final class InstallSessionsAdapter extends ListAdapter { - private final static SessionDiffCallback DIFF_CALLBACK = new SessionDiffCallback(); - private final static ItemAnimator ITEM_ANIMATOR = new ItemAnimator(); + private static final SessionDiffCallback DIFF_CALLBACK = new SessionDiffCallback(); + private static final ItemAnimator ITEM_ANIMATOR = new ItemAnimator(); private final Consumer onCancelClick; private final Consumer onItemSwipe; private final Handler handler = new Handler(Looper.getMainLooper()); @@ -59,7 +59,7 @@ public InstallSessionsAdapter(Consumer onCancelClick, Consumer onIte this.onItemSwipe = onItemSwipe; } - public final static class SessionViewHolder extends RecyclerView.ViewHolder { + public static final class SessionViewHolder extends RecyclerView.ViewHolder { private final ItemInstallSessionBinding binding; private final Consumer onClick; @@ -109,7 +109,7 @@ public void setProgress(@NonNull Progress sessionProgress, boolean animate) { R.string.percentage, (int) (((double) progress) / max * 100))); } - private void setError(@NonNull NotificationString error) { + private void setError(@NonNull ResolvableString error) { final var fade = new Fade(); fade.setDuration(150); TransitionManager.beginDelayedTransition(binding.getRoot(), fade); @@ -162,7 +162,7 @@ public void onBindViewHolder(@NonNull SessionViewHolder holder, int position, @N } holder.bind(sessionData); if (!payloads.isEmpty()) { - final var progressUpdate = (ProgressUpdate) payloads.get(0); + final var progressUpdate = (ProgressUpdate) payloads.get(payloads.size() - 1); holder.setProgress(progressUpdate.progress(), progressUpdate.animate()); } } @@ -188,7 +188,7 @@ private void notifyProgressChanged(@NonNull List progress) { private record ProgressUpdate(Progress progress, boolean animate) { } - private final static class SessionDiffCallback extends DiffUtil.ItemCallback { + private static final class SessionDiffCallback extends DiffUtil.ItemCallback { @Override public boolean areItemsTheSame(@NonNull SessionData oldItem, @NonNull SessionData newItem) { @@ -201,7 +201,7 @@ public boolean areContentsTheSame(@NonNull SessionData oldItem, @NonNull Session } } - private final static class ItemAnimator extends DefaultItemAnimator { + private static final class ItemAnimator extends DefaultItemAnimator { @Override public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallViewModel.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallViewModel.java index 3f792bff9..59f2af3ba 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallViewModel.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallViewModel.java @@ -51,16 +51,16 @@ import ru.solrudev.ackpine.installer.InstallFailure; import ru.solrudev.ackpine.installer.PackageInstaller; import ru.solrudev.ackpine.installer.parameters.InstallParameters; +import ru.solrudev.ackpine.resources.ResolvableString; import ru.solrudev.ackpine.sample.R; import ru.solrudev.ackpine.session.Failure; import ru.solrudev.ackpine.session.ProgressSession; import ru.solrudev.ackpine.session.Session; -import ru.solrudev.ackpine.session.parameters.NotificationString; import ru.solrudev.ackpine.splits.Apk; public final class InstallViewModel extends ViewModel { - private final MutableLiveData error = new MutableLiveData<>(); + private final MutableLiveData error = new MutableLiveData<>(); private final DisposableSubscriptionContainer subscriptions = new DisposableSubscriptionContainer(); private final PackageInstaller packageInstaller; private final SessionDataRepository sessionDataRepository; @@ -92,13 +92,12 @@ public void installPackage(@NonNull Sequence apks, @NonNull String fileName .build()); final var sessionData = new SessionData(session.getId(), fileName); sessionDataRepository.addSessionData(sessionData); - session.addStateListener(subscriptions, new SessionStateListener(session)); - session.addProgressListener(subscriptions, sessionDataRepository::updateSessionProgress); + addSessionListeners(session); }); } @NonNull - public LiveData getError() { + public LiveData getError() { return error; } @@ -113,7 +112,7 @@ public LiveData> getSessionsProgress() { } public void clearError() { - error.setValue(NotificationString.empty()); + error.setValue(ResolvableString.empty()); } public void cancelSession(@NonNull UUID id) { @@ -126,7 +125,7 @@ public void onSuccess(@Nullable ProgressSession session) { } @Override - public void onFailure(@NonNull Throwable t) { + public void onFailure(@NonNull Throwable t) { // no-op } }, MoreExecutors.directExecutor()); } @@ -153,17 +152,26 @@ private void addSessionListeners(@NonNull UUID id) { @Override public void onSuccess(@Nullable ProgressSession session) { if (session != null) { - session.addStateListener(subscriptions, new SessionStateListener(session)); - session.addProgressListener(subscriptions, sessionDataRepository::updateSessionProgress); + addSessionListeners(session); } } @Override - public void onFailure(@NonNull Throwable t) { + public void onFailure(@NonNull Throwable t) { // no-op } }, MoreExecutors.directExecutor()); } + private void addSessionListeners(@NonNull ProgressSession session) { + session.addStateListener(subscriptions, new SessionStateListener(session)); + session.addProgressListener(subscriptions, sessionDataRepository::updateSessionProgress); + session.addStateListener(subscriptions, (sessionId, state) -> { + if (state instanceof Session.State.Committed) { + sessionDataRepository.updateSessionIsCancellable(sessionId, false); + } + }); + } + private List getSessionsSnapshot() { return sessionDataRepository.getSessions().getValue(); } @@ -179,22 +187,22 @@ private List mapApkSequenceToUri(@NonNull Sequence apks) { return uris; } catch (SplitPackageException exception) { if (exception instanceof NoBaseApkException) { - error.postValue(NotificationString.resource(R.string.error_no_base_apk)); + error.postValue(ResolvableString.transientResource(R.string.error_no_base_apk)); } else if (exception instanceof ConflictingBaseApkException) { - error.postValue(NotificationString.resource(R.string.error_conflicting_base_apk)); + error.postValue(ResolvableString.transientResource(R.string.error_conflicting_base_apk)); } else if (exception instanceof ConflictingSplitNameException e) { - error.postValue(NotificationString.resource(R.string.error_conflicting_split_name, e.getName())); + error.postValue(ResolvableString.transientResource(R.string.error_conflicting_split_name, e.getName())); } else if (exception instanceof ConflictingPackageNameException e) { - error.postValue(NotificationString.resource(R.string.error_conflicting_package_name, + error.postValue(ResolvableString.transientResource(R.string.error_conflicting_package_name, e.getExpected(), e.getActual(), e.getName())); } else if (exception instanceof ConflictingVersionCodeException e) { - error.postValue(NotificationString.resource(R.string.error_conflicting_version_code, + error.postValue(ResolvableString.transientResource(R.string.error_conflicting_version_code, e.getExpected(), e.getActual(), e.getName())); } return Collections.emptyList(); } catch (Exception exception) { final var message = exception.getMessage() != null ? exception.getMessage() : ""; - error.postValue(NotificationString.raw(message)); + error.postValue(ResolvableString.raw(message)); Log.e("InstallViewModel", null, exception); return Collections.emptyList(); } @@ -220,8 +228,8 @@ public void onSuccess(@NonNull UUID sessionId) { public void onFailure(@NonNull UUID sessionId, @NonNull InstallFailure failure) { final var message = failure.getMessage(); final var error = message != null - ? NotificationString.resource(R.string.session_error_with_reason, message) - : NotificationString.resource(R.string.session_error); + ? ResolvableString.transientResource(R.string.session_error_with_reason, message) + : ResolvableString.transientResource(R.string.session_error); sessionDataRepository.setError(sessionId, error); if (failure instanceof Failure.Exceptional f) { Log.e("InstallViewModel", null, f.getException()); diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionData.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionData.java index f10934d84..2b5c41b9d 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionData.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionData.java @@ -22,17 +22,17 @@ import java.io.Serializable; import java.util.UUID; -import ru.solrudev.ackpine.session.parameters.NotificationString; +import ru.solrudev.ackpine.resources.ResolvableString; public record SessionData(@NonNull UUID id, @NonNull String name, - @NonNull NotificationString error, + @NonNull ResolvableString error, boolean isCancellable) implements Serializable { @Serial private static final long serialVersionUID = -7412725679599146483L; public SessionData(@NonNull UUID id, @NonNull String name) { - this(id, name, NotificationString.empty(), true); + this(id, name, ResolvableString.empty(), true); } } \ No newline at end of file diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepository.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepository.java index 139d11ea6..dcd085ac5 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepository.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepository.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,8 +22,8 @@ import java.util.List; import java.util.UUID; +import ru.solrudev.ackpine.resources.ResolvableString; import ru.solrudev.ackpine.session.Progress; -import ru.solrudev.ackpine.session.parameters.NotificationString; public interface SessionDataRepository { @@ -39,5 +39,7 @@ public interface SessionDataRepository { void updateSessionProgress(@NonNull UUID id, @NonNull Progress progress); - void setError(@NonNull UUID id, @NonNull NotificationString error); + void updateSessionIsCancellable(@NonNull UUID id, boolean isCancellable); + + void setError(@NonNull UUID id, @NonNull ResolvableString error); } diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.java index 7b8b06878..5b16e8d26 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.java @@ -26,8 +26,8 @@ import java.util.Objects; import java.util.UUID; +import ru.solrudev.ackpine.resources.ResolvableString; import ru.solrudev.ackpine.session.Progress; -import ru.solrudev.ackpine.session.parameters.NotificationString; public final class SessionDataRepositoryImpl implements SessionDataRepository { @@ -87,24 +87,26 @@ public void updateSessionProgress(@NonNull UUID id, @NonNull Progress progress) sessionsProgress.set(sessionProgressIndex, new SessionProgress(id, progress)); } this.sessionsProgress.setValue(sessionsProgress); - if (progress.getProgress() <= 80) { + } + + @Override + public void updateSessionIsCancellable(@NonNull UUID id, boolean isCancellable) { + final var sessionDataIndex = getSessionDataIndexById(getCurrentSessions(), id); + if (sessionDataIndex == -1) { return; } - final var sessionDataIndex = getSessionDataIndexById(getCurrentSessions(), id); - if (sessionDataIndex != -1) { - final var sessionData = getCurrentSessions().get(sessionDataIndex); - if (!sessionData.isCancellable()) { - return; - } - final var sessions = getCurrentSessionsCopy(); - sessions.set(sessionDataIndex, - new SessionData(sessionData.id(), sessionData.name(), sessionData.error(), false)); - this.sessions.setValue(sessions); + final var sessionData = getCurrentSessions().get(sessionDataIndex); + if (sessionData.isCancellable() == isCancellable) { + return; } + final var sessions = getCurrentSessionsCopy(); + sessions.set(sessionDataIndex, + new SessionData(sessionData.id(), sessionData.name(), sessionData.error(), isCancellable)); + this.sessions.setValue(sessions); } @Override - public void setError(@NonNull UUID id, @NonNull NotificationString error) { + public void setError(@NonNull UUID id, @NonNull ResolvableString error) { final var sessions = getCurrentSessionsCopy(); final var sessionDataIndex = getSessionDataIndexById(sessions, id); if (sessionDataIndex != -1) { diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/ApplicationsAdapter.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/ApplicationsAdapter.java index f939eed6b..0af873f8d 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/ApplicationsAdapter.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/ApplicationsAdapter.java @@ -29,7 +29,7 @@ public final class ApplicationsAdapter extends ListAdapter { - private final static ApplicationDiffCallback DIFF_CALLBACK = new ApplicationDiffCallback(); + private static final ApplicationDiffCallback DIFF_CALLBACK = new ApplicationDiffCallback(); private final Consumer onClick; public ApplicationsAdapter(Consumer onClick) { @@ -37,7 +37,7 @@ public ApplicationsAdapter(Consumer onClick) { this.onClick = onClick; } - public final static class ApplicationViewHolder extends RecyclerView.ViewHolder { + public static final class ApplicationViewHolder extends RecyclerView.ViewHolder { private final ItemApplicationBinding binding; private final Consumer onClick; @@ -73,7 +73,7 @@ public void onBindViewHolder(@NonNull ApplicationViewHolder holder, int position holder.bind(applicationData); } - private final static class ApplicationDiffCallback extends DiffUtil.ItemCallback { + private static final class ApplicationDiffCallback extends DiffUtil.ItemCallback { @Override public boolean areItemsTheSame(@NonNull ApplicationData oldItem, @NonNull ApplicationData newItem) { return oldItem.packageName().equals(newItem.packageName()); diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.java index 64e3cce30..1b5d92051 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.java @@ -51,8 +51,8 @@ public final class UninstallViewModel extends ViewModel { - private final static String SESSION_ID_KEY = "SESSION_ID"; - private final static String PACKAGE_NAME_KEY = "PACKAGE_NAME"; + private static final String SESSION_ID_KEY = "SESSION_ID"; + private static final String PACKAGE_NAME_KEY = "PACKAGE_NAME"; private final MutableLiveData isLoading = new MutableLiveData<>(false); private final MutableLiveData> applications = new MutableLiveData<>(new ArrayList<>()); private final DisposableSubscriptionContainer subscriptions = new DisposableSubscriptionContainer(); @@ -138,7 +138,7 @@ public void onSuccess(@Nullable Session session) { } @Override - public void onFailure(@NonNull Throwable t) { + public void onFailure(@NonNull Throwable t) { // no-op } }, MoreExecutors.directExecutor()); } @@ -153,7 +153,7 @@ public void onSuccess(@Nullable Session session) { } @Override - public void onFailure(@NonNull Throwable t) { + public void onFailure(@NonNull Throwable t) { // no-op } }, MoreExecutors.directExecutor()); } diff --git a/sample-ktx/build.gradle.kts b/sample-ktx/build.gradle.kts index 06d72baa4..560f84f68 100644 --- a/sample-ktx/build.gradle.kts +++ b/sample-ktx/build.gradle.kts @@ -70,6 +70,7 @@ tasks.withType().configureEach { dependencies { implementation(projects.ackpineSplits) implementation(projects.ackpineKtx) + implementation(projects.ackpineResources) implementation(androidx.activity) implementation(androidx.appcompat) implementation(androidx.recyclerview) diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt index 3976085a9..992641684 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt @@ -109,7 +109,7 @@ class InstallFragment : Fragment(R.layout.fragment_install) { private fun chooseFile() { try { pickerLauncher.launch("*/*") - } catch (_: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { // no-op } } diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.kt index 1d98f092e..f19e8ca6c 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallSessionsAdapter.kt @@ -28,10 +28,10 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import androidx.transition.Fade import androidx.transition.TransitionManager +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.sample.R import ru.solrudev.ackpine.sample.databinding.ItemInstallSessionBinding import ru.solrudev.ackpine.session.Progress -import ru.solrudev.ackpine.session.parameters.NotificationString import java.util.UUID class InstallSessionsAdapter( @@ -87,7 +87,7 @@ class InstallSessionsAdapter( ) } - private fun setError(error: NotificationString) = with(itemBinding) { + private fun setError(error: ResolvableString) = with(itemBinding) { TransitionManager.beginDelayedTransition(root, Fade().apply { duration = 150 }) val hasError = !error.isEmpty textViewSessionName.isVisible = !hasError @@ -133,7 +133,7 @@ class InstallSessionsAdapter( } holder.bind(sessionData) if (payloads.isNotEmpty()) { - val progressUpdate = payloads.first() as ProgressUpdate + val progressUpdate = payloads.last() as ProgressUpdate holder.setProgress(progressUpdate.progress, progressUpdate.animate) } } diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallUiState.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallUiState.kt index 223123b6c..3621e028a 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallUiState.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallUiState.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,10 @@ package ru.solrudev.ackpine.sample.install -import ru.solrudev.ackpine.session.parameters.NotificationString +import ru.solrudev.ackpine.resources.ResolvableString data class InstallUiState( - val error: NotificationString = NotificationString.empty(), + val error: ResolvableString = ResolvableString.empty(), val sessions: List = emptyList(), val sessionsProgress: List = emptyList() ) \ No newline at end of file diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallViewModel.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallViewModel.kt index 21ac14c56..a1a2b1b88 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallViewModel.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallViewModel.kt @@ -30,11 +30,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible @@ -48,12 +48,14 @@ import ru.solrudev.ackpine.installer.InstallFailure import ru.solrudev.ackpine.installer.PackageInstaller import ru.solrudev.ackpine.installer.createSession import ru.solrudev.ackpine.installer.getSession +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.sample.R import ru.solrudev.ackpine.session.ProgressSession +import ru.solrudev.ackpine.session.Session import ru.solrudev.ackpine.session.SessionResult import ru.solrudev.ackpine.session.await -import ru.solrudev.ackpine.session.parameters.NotificationString import ru.solrudev.ackpine.session.progress +import ru.solrudev.ackpine.session.state import ru.solrudev.ackpine.splits.Apk import java.util.UUID @@ -62,30 +64,16 @@ class InstallViewModel( private val sessionDataRepository: SessionDataRepository ) : ViewModel() { - private val awaitSessionsFromSavedState = channelFlow { - val sessions = sessionDataRepository.sessions.value - if (sessions.isNotEmpty()) { - sessions - .map { sessionData -> - async { packageInstaller.getSession(sessionData.id) } - } - .awaitAll() - .filterNotNull() - .forEach(::awaitSession) - } - } + private val error = MutableStateFlow(ResolvableString.empty()) - private val error = MutableStateFlow(NotificationString.empty()) - - val uiState = merge( - awaitSessionsFromSavedState, - combine( - error, - sessionDataRepository.sessions, - sessionDataRepository.sessionsProgress, - ::InstallUiState - ) - ).stateIn(viewModelScope, SharingStarted.Lazily, InstallUiState()) + val uiState = combine( + error, + sessionDataRepository.sessions, + sessionDataRepository.sessionsProgress, + ::InstallUiState + ) + .onStart { awaitSessionsFromSavedState() } + .stateIn(viewModelScope, SharingStarted.Lazily, InstallUiState()) fun installPackage(apks: Sequence, fileName: String) = viewModelScope.launch { val uris = runInterruptible(Dispatchers.IO) { apks.toUrisList() } @@ -108,13 +96,30 @@ class InstallViewModel( fun removeSession(id: UUID) = sessionDataRepository.removeSessionData(id) fun clearError() { - error.value = NotificationString.empty() + error.value = ResolvableString.empty() + } + + private fun awaitSessionsFromSavedState() = viewModelScope.launch { + val sessions = this@InstallViewModel.sessionDataRepository.sessions.value + if (sessions.isNotEmpty()) { + sessions + .map { sessionData -> + async { this@InstallViewModel.packageInstaller.getSession(sessionData.id) } + } + .awaitAll() + .filterNotNull() + .forEach(::awaitSession) + } } private fun awaitSession(session: ProgressSession) = viewModelScope.launch { session.progress .onEach { progress -> sessionDataRepository.updateSessionProgress(session.id, progress) } .launchIn(this) + session.state + .filterIsInstance() + .onEach { sessionDataRepository.updateSessionIsCancellable(session.id, isCancellable = false) } + .launchIn(this) try { when (val result = session.await()) { is SessionResult.Success -> sessionDataRepository.removeSessionData(session.id) @@ -131,9 +136,9 @@ class InstallViewModel( private fun handleSessionError(message: String?, sessionId: UUID) { val error = if (message != null) { - NotificationString.resource(R.string.session_error_with_reason, message) + ResolvableString.transientResource(R.string.session_error_with_reason, message) } else { - NotificationString.resource(R.string.session_error) + ResolvableString.transientResource(R.string.session_error) } sessionDataRepository.setError(sessionId, error) } @@ -143,19 +148,19 @@ class InstallViewModel( return map { it.uri }.toList() } catch (exception: SplitPackageException) { val errorString = when (exception) { - is NoBaseApkException -> NotificationString.resource(R.string.error_no_base_apk) - is ConflictingBaseApkException -> NotificationString.resource(R.string.error_conflicting_base_apk) - is ConflictingSplitNameException -> NotificationString.resource( + is NoBaseApkException -> ResolvableString.transientResource(R.string.error_no_base_apk) + is ConflictingBaseApkException -> ResolvableString.transientResource(R.string.error_conflicting_base_apk) + is ConflictingSplitNameException -> ResolvableString.transientResource( R.string.error_conflicting_split_name, exception.name ) - is ConflictingPackageNameException -> NotificationString.resource( + is ConflictingPackageNameException -> ResolvableString.transientResource( R.string.error_conflicting_package_name, exception.expected, exception.actual, exception.name ) - is ConflictingVersionCodeException -> NotificationString.resource( + is ConflictingVersionCodeException -> ResolvableString.transientResource( R.string.error_conflicting_version_code, exception.expected, exception.actual, exception.name ) @@ -163,7 +168,7 @@ class InstallViewModel( error.value = errorString return emptyList() } catch (exception: Exception) { - error.value = NotificationString.raw(exception.message.orEmpty()) + error.value = ResolvableString.raw(exception.message.orEmpty()) Log.e("InstallViewModel", null, exception) return emptyList() } diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionData.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionData.kt index af98b159d..e41cb061a 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionData.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionData.kt @@ -16,14 +16,14 @@ package ru.solrudev.ackpine.sample.install -import ru.solrudev.ackpine.session.parameters.NotificationString +import ru.solrudev.ackpine.resources.ResolvableString import java.io.Serializable import java.util.UUID data class SessionData( val id: UUID, val name: String, - val error: NotificationString = NotificationString.empty(), + val error: ResolvableString = ResolvableString.empty(), val isCancellable: Boolean = true ) : Serializable { private companion object { diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepository.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepository.kt index b766c7cf3..8e18c7fd6 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepository.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepository.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ package ru.solrudev.ackpine.sample.install import kotlinx.coroutines.flow.StateFlow +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.session.Progress -import ru.solrudev.ackpine.session.parameters.NotificationString import java.util.UUID interface SessionDataRepository { @@ -27,5 +27,6 @@ interface SessionDataRepository { fun addSessionData(sessionData: SessionData) fun removeSessionData(id: UUID) fun updateSessionProgress(id: UUID, progress: Progress) - fun setError(id: UUID, error: NotificationString) + fun updateSessionIsCancellable(id: UUID, isCancellable: Boolean) + fun setError(id: UUID, error: ResolvableString) } \ No newline at end of file diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.kt index 60e48e30c..675908840 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/SessionDataRepositoryImpl.kt @@ -17,8 +17,8 @@ package ru.solrudev.ackpine.sample.install import androidx.lifecycle.SavedStateHandle +import ru.solrudev.ackpine.resources.ResolvableString import ru.solrudev.ackpine.session.Progress -import ru.solrudev.ackpine.session.parameters.NotificationString import java.util.UUID private const val SESSIONS_KEY = "SESSIONS" @@ -67,22 +67,23 @@ class SessionDataRepositoryImpl(private val savedStateHandle: SavedStateHandle) sessionsProgress[sessionProgressIndex] = SessionProgress(id, progress) } _sessionsProgress = sessionsProgress - if (progress.progress <= 80) { + } + + override fun updateSessionIsCancellable(id: UUID, isCancellable: Boolean) { + val sessionDataIndex = _sessions.indexOfFirst { it.id == id } + if (sessionDataIndex == -1) { return } - val sessionDataIndex = _sessions.indexOfFirst { it.id == id } - if (sessionDataIndex != -1) { - val sessionData = _sessions[sessionDataIndex] - if (!sessionData.isCancellable) { - return - } - val sessions = _sessions.toMutableList() - sessions[sessionDataIndex] = sessionData.copy(isCancellable = false) - _sessions = sessions + val sessionData = _sessions[sessionDataIndex] + if (sessionData.isCancellable == isCancellable) { + return } + val sessions = _sessions.toMutableList() + sessions[sessionDataIndex] = sessionData.copy(isCancellable = isCancellable) + _sessions = sessions } - override fun setError(id: UUID, error: NotificationString) { + override fun setError(id: UUID, error: ResolvableString) { val sessions = _sessions.toMutableList() val sessionDataIndex = sessions.indexOfFirst { it.id == id } if (sessionDataIndex != -1) { diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.kt index 2dd0414f8..a4a62dd27 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/uninstall/UninstallViewModel.kt @@ -27,8 +27,9 @@ import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible @@ -50,15 +51,11 @@ class UninstallViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val awaitSessionFromSavedState = flow { - val sessionId = savedStateHandle.get(SESSION_ID_KEY) - if (sessionId != null) { - packageUninstaller.getSession(sessionId)?.let(::awaitSession) - } - } - private val _uiState = MutableStateFlow(UninstallUiState()) - val uiState = merge(awaitSessionFromSavedState, _uiState) + + val uiState = _uiState + .onStart { awaitSessionFromSavedState() } + .stateIn(viewModelScope, SharingStarted.Lazily, UninstallUiState()) fun loadApplications(refresh: Boolean, applicationsFactory: () -> List) { if (!refresh && _uiState.value.applications.isNotEmpty()) { @@ -86,6 +83,13 @@ class UninstallViewModel( _uiState.update { it.copy(applications = applications) } } + private fun awaitSessionFromSavedState() = viewModelScope.launch { + val sessionId = savedStateHandle.get(SESSION_ID_KEY) + if (sessionId != null) { + packageUninstaller.getSession(sessionId)?.let(::awaitSession) + } + } + private fun clearSavedState() { savedStateHandle.remove(SESSION_ID_KEY) savedStateHandle.remove(PACKAGE_NAME_KEY) diff --git a/settings.gradle.kts b/settings.gradle.kts index e9d61eff1..7f9a853ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -56,5 +56,6 @@ include(":ackpine-ktx") include(":ackpine-splits") include(":ackpine-assets") include(":ackpine-runtime") +include(":ackpine-resources") include(":sample-java") include(":sample-ktx") \ No newline at end of file diff --git a/version.properties b/version.properties index 893c010db..6b81380b9 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ MAJOR_VERSION=0 -MINOR_VERSION=7 -PATCH_VERSION=6 +MINOR_VERSION=8 +PATCH_VERSION=0 SUFFIX= SNAPSHOT=false \ No newline at end of file