diff --git a/README.md b/README.md index d639c6653a..1315bbb920 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # DataBackup -[![Download](https://img.shields.io/github/downloads/XayahSuSuSu/Android-DataBackup/total)](https://github.com/XayahSuSuSu/Android-DataBackup/releases) [![GitHub release](https://img.shields.io/github/v/release/XayahSuSuSu/Android-DataBackup?color=orange)](https://github.com/XayahSuSuSu/Android-DataBackup/releases) [![License](https://img.shields.io/github/license/XayahSuSuSu/Android-DataBackup?color=ff69b4)](./LICENSE) +[![Download](https://img.shields.io/github/downloads/XayahSuSuSu/Android-DataBackup/total)](https://github.com/XayahSuSuSu/Android-DataBackup/releases) [![GitHub release](https://img.shields.io/github/v/release/XayahSuSuSu/Android-DataBackup?color=orange)](https://github.com/XayahSuSuSu/Android-DataBackup/releases) [![License](https://img.shields.io/github/license/XayahSuSuSu/Android-DataBackup?color=ff69b4)](./LICENSE) [![Telegram](https://img.shields.io/badge/telegram-DataBackup-252850?color=blue&logo=telegram)](https://t.me/+iXhapJkCxAU4MGE9) > :star: Based on [speed-backup](https://github.com/YAWAsau/backup_script) by [CoolApk@落叶凄凉TEL](http://www.coolapk.com/u/2277637) > @@ -27,7 +27,10 @@ * :rose: **...** ## Screenshot -None +
+ + +
## Download [ built_in/version zip -pj built_in/$TARGET_ARCH/bin coreutls/bin/df tar/bin/tar zstd/bin/zstd built_in/version tree/tree - rm -rf ../app/src/$TARGET_ARCH/assets/bin/bin.zip - cp built_in/$TARGET_ARCH/bin.zip ../app/src/$TARGET_ARCH/assets/bin/bin.zip } package_extend() { @@ -408,8 +410,6 @@ package_extend() { mkdir -p extend echo "$EXTEND_VERSION" > extend/version zip -pj extend/$TARGET_ARCH fuse/bin/fusermount rclone/rclone extend/version - rm -rf ../extend/${TARGET_ARCH}.zip - cp extend/${TARGET_ARCH}.zip ../extend/${TARGET_ARCH}.zip } build() { diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000000..d7718edccc --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,11 @@ +DataBackup is based on speed-backup. It was born with the consent of the author. + +DataBackup is intended to back up and restore the data of your apps. It works on arm64-v8a, armeabi-v7a, x86 and x86_64 devices with Android 9 or higher. + +
Features include: + +* Multi-user Support +* 100% Data Integrity +* Fast +* Easy +* ... diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000000..011f256a06 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg new file mode 100644 index 0000000000..94e09f39ae Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg new file mode 100644 index 0000000000..887259f98f Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg new file mode 100644 index 0000000000..4352f11378 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg new file mode 100644 index 0000000000..a550901522 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg new file mode 100644 index 0000000000..194feed475 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg new file mode 100644 index 0000000000..c6493176b8 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000000..28f1516f7c --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Back up/Restore your data. \ No newline at end of file diff --git a/source/app/build.gradle.kts b/source/app/build.gradle.kts index 4b9bb7e339..e68edf49ba 100644 --- a/source/app/build.gradle.kts +++ b/source/app/build.gradle.kts @@ -25,41 +25,32 @@ android { vectorDrawables { useSupportLibrary = true } - - javaCompileOptions { - annotationProcessorOptions { - arguments += "room.schemaLocation" to "$projectDir/schemas" - } - } } - // __(API)_(feature)_(abi)___(version) flavorDimensions += listOf("abi", "feature") productFlavors { create("arm64-v8a") { dimension = "abi" - versionCode = 1000 + (android.defaultConfig.versionCode ?: 0) + versionCode = 4 + (android.defaultConfig.versionCode ?: 0) } create("armeabi-v7a") { dimension = "abi" - versionCode = 2000 + (android.defaultConfig.versionCode ?: 0) + versionCode = 3 + (android.defaultConfig.versionCode ?: 0) } create("x86") { dimension = "abi" - versionCode = 3000 + (android.defaultConfig.versionCode ?: 0) + versionCode = 2 + (android.defaultConfig.versionCode ?: 0) } create("x86_64") { dimension = "abi" - versionCode = 4000 + (android.defaultConfig.versionCode ?: 0) + versionCode = 1 + (android.defaultConfig.versionCode ?: 0) } create("foss") { dimension = "feature" - versionCode = 10000 + (android.defaultConfig.versionCode ?: 0) applicationIdSuffix = ".foss" } create("premium") { dimension = "feature" - versionCode = 20000 + (android.defaultConfig.versionCode ?: 0) applicationIdSuffix = ".premium" } } @@ -165,8 +156,15 @@ dependencies { "premiumImplementation"(platform(libs.firebase.bom)) "premiumImplementation"(libs.firebase.crashlytics) "premiumImplementation"(libs.firebase.analytics) + + // PickYou + implementation(libs.pickyou) } kapt { correctErrorTypes = true } + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} diff --git a/source/app/schemas/com.xayah.databackup.data.AppDatabase/1.json b/source/app/schemas/com.xayah.databackup.data.AppDatabase/1.json new file mode 100644 index 0000000000..e65c0b1e30 --- /dev/null +++ b/source/app/schemas/com.xayah.databackup.data.AppDatabase/1.json @@ -0,0 +1,575 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "8f337a1137874a704facf08344affa87", + "entities": [ + { + "tableName": "LogEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `startTimestamp` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `msg` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "msg", + "columnName": "msg", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "CmdEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `logId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `type` TEXT NOT NULL, `msg` TEXT NOT NULL, FOREIGN KEY(`logId`) REFERENCES `LogEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logId", + "columnName": "logId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "msg", + "columnName": "msg", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_CmdEntity_logId", + "unique": false, + "columnNames": [ + "logId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_CmdEntity_logId` ON `${TABLE_NAME}` (`logId`)" + } + ], + "foreignKeys": [ + { + "table": "LogEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "logId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PackageBackupEntire", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `operationCode` INTEGER NOT NULL DEFAULT 0, `timestamp` INTEGER NOT NULL DEFAULT 0, `versionName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `firstInstallTime` INTEGER NOT NULL, `active` INTEGER NOT NULL, `appBytes` INTEGER NOT NULL, `cacheBytes` INTEGER NOT NULL, `dataBytes` INTEGER NOT NULL, `externalCacheBytes` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "operationCode", + "columnName": "operationCode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "versionName", + "columnName": "versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstInstallTime", + "columnName": "firstInstallTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.appBytes", + "columnName": "appBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.cacheBytes", + "columnName": "cacheBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.dataBytes", + "columnName": "dataBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.externalCacheBytes", + "columnName": "externalCacheBytes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PackageBackupOperation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, `startTimestamp` INTEGER NOT NULL DEFAULT 0, `endTimestamp` INTEGER NOT NULL DEFAULT 0, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `packageState` INTEGER NOT NULL, `apkLog` TEXT NOT NULL, `apkState` TEXT NOT NULL DEFAULT 'IDLE', `userLog` TEXT NOT NULL, `userState` TEXT NOT NULL DEFAULT 'IDLE', `userDeLog` TEXT NOT NULL, `userDeState` TEXT NOT NULL DEFAULT 'IDLE', `dataLog` TEXT NOT NULL, `dataState` TEXT NOT NULL DEFAULT 'IDLE', `obbLog` TEXT NOT NULL, `obbState` TEXT NOT NULL DEFAULT 'IDLE', `mediaLog` TEXT NOT NULL, `mediaState` TEXT NOT NULL DEFAULT 'IDLE')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageState", + "columnName": "packageState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apkLog", + "columnName": "apkLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apkState", + "columnName": "apkState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userLog", + "columnName": "userLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userState", + "columnName": "userState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userDeLog", + "columnName": "userDeLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userDeState", + "columnName": "userDeState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "dataLog", + "columnName": "dataLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataState", + "columnName": "dataState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "obbLog", + "columnName": "obbLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "obbState", + "columnName": "obbState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "mediaLog", + "columnName": "mediaLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaState", + "columnName": "mediaState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PackageRestoreEntire", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `backupOpCode` INTEGER NOT NULL DEFAULT 0, `operationCode` INTEGER NOT NULL DEFAULT 0, `timestamp` INTEGER NOT NULL DEFAULT 0, `versionName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `compressionType` TEXT NOT NULL, `active` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backupOpCode", + "columnName": "backupOpCode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "operationCode", + "columnName": "operationCode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "versionName", + "columnName": "versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "compressionType", + "columnName": "compressionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PackageRestoreOperation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, `startTimestamp` INTEGER NOT NULL DEFAULT 0, `endTimestamp` INTEGER NOT NULL DEFAULT 0, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `packageState` INTEGER NOT NULL, `apkLog` TEXT NOT NULL, `apkState` TEXT NOT NULL DEFAULT 'IDLE', `userLog` TEXT NOT NULL, `userState` TEXT NOT NULL DEFAULT 'IDLE', `userDeLog` TEXT NOT NULL, `userDeState` TEXT NOT NULL DEFAULT 'IDLE', `dataLog` TEXT NOT NULL, `dataState` TEXT NOT NULL DEFAULT 'IDLE', `obbLog` TEXT NOT NULL, `obbState` TEXT NOT NULL DEFAULT 'IDLE', `mediaLog` TEXT NOT NULL, `mediaState` TEXT NOT NULL DEFAULT 'IDLE')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageState", + "columnName": "packageState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apkLog", + "columnName": "apkLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apkState", + "columnName": "apkState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userLog", + "columnName": "userLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userState", + "columnName": "userState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userDeLog", + "columnName": "userDeLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userDeState", + "columnName": "userDeState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "dataLog", + "columnName": "dataLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataState", + "columnName": "dataState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "obbLog", + "columnName": "obbLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "obbState", + "columnName": "obbState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "mediaLog", + "columnName": "mediaLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaState", + "columnName": "mediaState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8f337a1137874a704facf08344affa87')" + ] + } +} \ No newline at end of file diff --git a/source/app/schemas/com.xayah.databackup.data.AppDatabase/2.json b/source/app/schemas/com.xayah.databackup.data.AppDatabase/2.json new file mode 100644 index 0000000000..b96447e949 --- /dev/null +++ b/source/app/schemas/com.xayah.databackup.data.AppDatabase/2.json @@ -0,0 +1,942 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "5175b94dec2c75d076ce8b653fa69c0b", + "entities": [ + { + "tableName": "LogEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `startTimestamp` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `msg` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "msg", + "columnName": "msg", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "CmdEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `logId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `type` TEXT NOT NULL, `msg` TEXT NOT NULL, FOREIGN KEY(`logId`) REFERENCES `LogEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logId", + "columnName": "logId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "msg", + "columnName": "msg", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_CmdEntity_logId", + "unique": false, + "columnNames": [ + "logId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_CmdEntity_logId` ON `${TABLE_NAME}` (`logId`)" + } + ], + "foreignKeys": [ + { + "table": "LogEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "logId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PackageBackupEntire", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `operationCode` INTEGER NOT NULL DEFAULT 0, `timestamp` INTEGER NOT NULL DEFAULT 0, `versionName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `firstInstallTime` INTEGER NOT NULL, `active` INTEGER NOT NULL, `appBytes` INTEGER NOT NULL, `cacheBytes` INTEGER NOT NULL, `dataBytes` INTEGER NOT NULL, `externalCacheBytes` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "operationCode", + "columnName": "operationCode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "versionName", + "columnName": "versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstInstallTime", + "columnName": "firstInstallTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.appBytes", + "columnName": "appBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.cacheBytes", + "columnName": "cacheBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.dataBytes", + "columnName": "dataBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageStats.externalCacheBytes", + "columnName": "externalCacheBytes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PackageBackupOperation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, `startTimestamp` INTEGER NOT NULL DEFAULT 0, `endTimestamp` INTEGER NOT NULL DEFAULT 0, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `packageState` INTEGER NOT NULL, `apkLog` TEXT NOT NULL, `apkState` TEXT NOT NULL DEFAULT 'IDLE', `userLog` TEXT NOT NULL, `userState` TEXT NOT NULL DEFAULT 'IDLE', `userDeLog` TEXT NOT NULL, `userDeState` TEXT NOT NULL DEFAULT 'IDLE', `dataLog` TEXT NOT NULL, `dataState` TEXT NOT NULL DEFAULT 'IDLE', `obbLog` TEXT NOT NULL, `obbState` TEXT NOT NULL DEFAULT 'IDLE', `mediaLog` TEXT NOT NULL, `mediaState` TEXT NOT NULL DEFAULT 'IDLE')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageState", + "columnName": "packageState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apkLog", + "columnName": "apkLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apkState", + "columnName": "apkState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userLog", + "columnName": "userLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userState", + "columnName": "userState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userDeLog", + "columnName": "userDeLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userDeState", + "columnName": "userDeState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "dataLog", + "columnName": "dataLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataState", + "columnName": "dataState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "obbLog", + "columnName": "obbLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "obbState", + "columnName": "obbState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "mediaLog", + "columnName": "mediaLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaState", + "columnName": "mediaState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PackageRestoreEntire", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `backupOpCode` INTEGER NOT NULL DEFAULT 0, `operationCode` INTEGER NOT NULL DEFAULT 0, `timestamp` INTEGER NOT NULL DEFAULT 0, `versionName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `compressionType` TEXT NOT NULL, `active` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backupOpCode", + "columnName": "backupOpCode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "operationCode", + "columnName": "operationCode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "versionName", + "columnName": "versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "compressionType", + "columnName": "compressionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PackageRestoreOperation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, `startTimestamp` INTEGER NOT NULL DEFAULT 0, `endTimestamp` INTEGER NOT NULL DEFAULT 0, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `packageState` INTEGER NOT NULL, `apkLog` TEXT NOT NULL, `apkState` TEXT NOT NULL DEFAULT 'IDLE', `userLog` TEXT NOT NULL, `userState` TEXT NOT NULL DEFAULT 'IDLE', `userDeLog` TEXT NOT NULL, `userDeState` TEXT NOT NULL DEFAULT 'IDLE', `dataLog` TEXT NOT NULL, `dataState` TEXT NOT NULL DEFAULT 'IDLE', `obbLog` TEXT NOT NULL, `obbState` TEXT NOT NULL DEFAULT 'IDLE', `mediaLog` TEXT NOT NULL, `mediaState` TEXT NOT NULL DEFAULT 'IDLE')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageState", + "columnName": "packageState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apkLog", + "columnName": "apkLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apkState", + "columnName": "apkState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userLog", + "columnName": "userLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userState", + "columnName": "userState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "userDeLog", + "columnName": "userDeLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userDeState", + "columnName": "userDeState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "dataLog", + "columnName": "dataLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataState", + "columnName": "dataState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "obbLog", + "columnName": "obbLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "obbState", + "columnName": "obbState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + }, + { + "fieldPath": "mediaLog", + "columnName": "mediaLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaState", + "columnName": "mediaState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'IDLE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DirectoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `parent` TEXT NOT NULL, `child` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '[]', `error` TEXT NOT NULL DEFAULT '', `availableBytes` INTEGER NOT NULL DEFAULT 0, `totalBytes` INTEGER NOT NULL DEFAULT 0, `directoryType` TEXT NOT NULL DEFAULT 'BACKUP', `storageType` TEXT NOT NULL DEFAULT 'INTERNAL', `selected` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `active` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "child", + "columnName": "child", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "availableBytes", + "columnName": "availableBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalBytes", + "columnName": "totalBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directoryType", + "columnName": "directoryType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'BACKUP'" + }, + { + "fieldPath": "storageType", + "columnName": "storageType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'INTERNAL'" + }, + { + "fieldPath": "selected", + "columnName": "selected", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaBackupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`path` TEXT NOT NULL, `name` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL DEFAULT 0, `selected` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`path`))", + "fields": [ + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "selected", + "columnName": "selected", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "path" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaRestoreEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `selected` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "selected", + "columnName": "selected", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaBackupOperationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `startTimestamp` INTEGER NOT NULL, `endTimestamp` INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `opLog` TEXT NOT NULL, `opState` TEXT NOT NULL, `state` INTEGER NOT NULL, FOREIGN KEY(`path`) REFERENCES `MediaBackupEntity`(`path`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opLog", + "columnName": "opLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opState", + "columnName": "opState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "MediaBackupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "path" + ], + "referencedColumns": [ + "path" + ] + } + ] + }, + { + "tableName": "MediaRestoreOperationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `entityId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `startTimestamp` INTEGER NOT NULL, `endTimestamp` INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `opLog` TEXT NOT NULL, `opState` TEXT NOT NULL, `state` INTEGER NOT NULL, FOREIGN KEY(`entityId`) REFERENCES `MediaRestoreEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entityId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTimestamp", + "columnName": "startTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimestamp", + "columnName": "endTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opLog", + "columnName": "opLog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opState", + "columnName": "opState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MediaRestoreOperationEntity_entityId", + "unique": false, + "columnNames": [ + "entityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MediaRestoreOperationEntity_entityId` ON `${TABLE_NAME}` (`entityId`)" + } + ], + "foreignKeys": [ + { + "table": "MediaRestoreEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "entityId" + ], + "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, '5175b94dec2c75d076ce8b653fa69c0b')" + ] + } +} \ No newline at end of file diff --git a/source/app/src/arm64-v8a/assets/bin.zip b/source/app/src/arm64-v8a/assets/bin.zip index c4f1e2b9da..c6ee0478d6 100644 Binary files a/source/app/src/arm64-v8a/assets/bin.zip and b/source/app/src/arm64-v8a/assets/bin.zip differ diff --git a/source/app/src/armeabi-v7a/assets/bin.zip b/source/app/src/armeabi-v7a/assets/bin.zip index 3c0736c06a..1cb89175ff 100644 Binary files a/source/app/src/armeabi-v7a/assets/bin.zip and b/source/app/src/armeabi-v7a/assets/bin.zip differ diff --git a/source/app/src/main/AndroidManifest.xml b/source/app/src/main/AndroidManifest.xml index 4fc26638ed..ab7d1d4ecc 100644 --- a/source/app/src/main/AndroidManifest.xml +++ b/source/app/src/main/AndroidManifest.xml @@ -46,5 +46,9 @@ + + \ No newline at end of file diff --git a/source/app/src/main/assets/bin b/source/app/src/main/assets/bin deleted file mode 100644 index 840ca8cbf3..0000000000 --- a/source/app/src/main/assets/bin +++ /dev/null @@ -1 +0,0 @@ -1.4 \ No newline at end of file diff --git a/source/app/src/main/java/com/xayah/databackup/DataBackupApplication.kt b/source/app/src/main/java/com/xayah/databackup/DataBackupApplication.kt index 6525dc7971..0a38a19411 100644 --- a/source/app/src/main/java/com/xayah/databackup/DataBackupApplication.kt +++ b/source/app/src/main/java/com/xayah/databackup/DataBackupApplication.kt @@ -10,7 +10,6 @@ import com.xayah.databackup.util.binPath import com.xayah.databackup.util.extendPath import com.xayah.databackup.util.filesPath import com.xayah.databackup.util.readMonetEnabled -import com.xayah.librootservice.service.RemoteRootService import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp @@ -56,7 +55,5 @@ class DataBackupApplication : Application() { super.onCreate() application = this monetEnabled = mutableStateOf(readMonetEnabled()) - // Kill daemon - RemoteRootService(this).destroyService(killDaemon = true) } } diff --git a/source/app/src/main/java/com/xayah/databackup/data/AppDatabase.kt b/source/app/src/main/java/com/xayah/databackup/data/AppDatabase.kt index 7cc6612e60..aa03259cc9 100644 --- a/source/app/src/main/java/com/xayah/databackup/data/AppDatabase.kt +++ b/source/app/src/main/java/com/xayah/databackup/data/AppDatabase.kt @@ -1,9 +1,13 @@ package com.xayah.databackup.data +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.TypeConverters @Database( + version = 2, + exportSchema = true, entities = [ LogEntity::class, CmdEntity::class, @@ -11,12 +15,23 @@ import androidx.room.RoomDatabase PackageBackupOperation::class, PackageRestoreEntire::class, PackageRestoreOperation::class, - ], version = 1 + DirectoryEntity::class, + MediaBackupEntity::class, + MediaRestoreEntity::class, + MediaBackupOperationEntity::class, + MediaRestoreOperationEntity::class, + ], + autoMigrations = [ + AutoMigration(from = 1, to = 2) + ] ) +@TypeConverters(StringListConverters::class) abstract class AppDatabase : RoomDatabase() { abstract fun logDao(): LogDao abstract fun packageBackupEntireDao(): PackageBackupEntireDao abstract fun packageBackupOperationDao(): PackageBackupOperationDao abstract fun packageRestoreEntireDao(): PackageRestoreEntireDao abstract fun packageRestoreOperationDao(): PackageRestoreOperationDao + abstract fun directoryDao(): DirectoryDao + abstract fun mediaDao(): MediaDao } diff --git a/source/app/src/main/java/com/xayah/databackup/data/Converters.kt b/source/app/src/main/java/com/xayah/databackup/data/Converters.kt new file mode 100644 index 0000000000..50a17105cd --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/data/Converters.kt @@ -0,0 +1,13 @@ +package com.xayah.databackup.data + +import androidx.room.TypeConverter +import com.google.gson.reflect.TypeToken +import com.xayah.databackup.util.GsonUtil + +class StringListConverters { + @TypeConverter + fun fromJson(json: String): List = GsonUtil().fromJson(json, object : TypeToken>() {}.type) + + @TypeConverter + fun toJson(list: List): String = GsonUtil().toJson(list) +} diff --git a/source/app/src/main/java/com/xayah/databackup/data/DirectoryDao.kt b/source/app/src/main/java/com/xayah/databackup/data/DirectoryDao.kt new file mode 100644 index 0000000000..83f83d3e32 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/data/DirectoryDao.kt @@ -0,0 +1,40 @@ +package com.xayah.databackup.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface DirectoryDao { + @Upsert(entity = DirectoryEntity::class) + suspend fun upsert(item: DirectoryEntity) + + @Upsert(entity = DirectoryEntity::class) + suspend fun upsert(items: List) + + @Query("SELECT * FROM DirectoryEntity WHERE active = 1") + fun queryActiveDirectoriesFlow(): Flow> + + @Query("SELECT * FROM DirectoryEntity WHERE active = 1") + suspend fun queryActiveDirectories(): List + + @Query("SELECT * FROM DirectoryEntity WHERE directoryType = :type AND selected = 1 LIMIT 1") + suspend fun querySelectedByDirectoryType(type: DirectoryType): DirectoryEntity? + + @Query("SELECT id FROM DirectoryEntity WHERE parent = :parent AND child = :child AND directoryType = :type LIMIT 1") + suspend fun queryId(parent: String, child: String, type: DirectoryType): Long + + @Query("UPDATE DirectoryEntity SET active = :active") + suspend fun updateActive(active: Boolean) + + @Query("UPDATE DirectoryEntity SET active = :active WHERE directoryType = :type AND storageType != :excludeType") + suspend fun updateActive(type: DirectoryType, excludeType: StorageType, active: Boolean) + + @Query("UPDATE DirectoryEntity SET selected = CASE WHEN id = :id THEN 1 ELSE 0 END WHERE directoryType = :type") + suspend fun select(type: DirectoryType, id: Long) + + @Delete(entity = DirectoryEntity::class) + suspend fun delete(item: DirectoryEntity) +} diff --git a/source/app/src/main/java/com/xayah/databackup/data/DirectoryEntity.kt b/source/app/src/main/java/com/xayah/databackup/data/DirectoryEntity.kt new file mode 100644 index 0000000000..5cfd33d6c2 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/data/DirectoryEntity.kt @@ -0,0 +1,56 @@ +package com.xayah.databackup.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +enum class DirectoryType { + BACKUP, + RESTORE, +} + +enum class StorageType { + INTERNAL, + EXTERNAL, + CUSTOM, +} + +@Entity +data class DirectoryEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + var title: String, + var parent: String, + var child: String, + @ColumnInfo(defaultValue = "[]") var tags: List, + @ColumnInfo(defaultValue = "") var error: String, + @ColumnInfo(defaultValue = "0") var availableBytes: Long, + @ColumnInfo(defaultValue = "0") var totalBytes: Long, + @ColumnInfo(defaultValue = "BACKUP") var directoryType: DirectoryType, + @ColumnInfo(defaultValue = "INTERNAL") var storageType: StorageType, + @ColumnInfo(defaultValue = "0") var selected: Boolean, + @ColumnInfo(defaultValue = "1") var enabled: Boolean, + @ColumnInfo(defaultValue = "0") var active: Boolean, +) { + val path: String + get() = "${parent}/${child}" + + val availableBytesDisplay: String + get() = formatSize(availableBytes.toDouble()) + + val totalBytesDisplay: String + get() = formatSize(totalBytes.toDouble()) +} + +@Entity +data class DirectoryUpsertEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + var title: String, + var parent: String, + var child: String, + var directoryType: DirectoryType, + var storageType: StorageType, + var active: Boolean = false, +) { + val path: String + get() = "${parent}/${child}" +} diff --git a/source/app/src/main/java/com/xayah/databackup/data/MediaDao.kt b/source/app/src/main/java/com/xayah/databackup/data/MediaDao.kt new file mode 100644 index 0000000000..8d8346d4e2 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/data/MediaDao.kt @@ -0,0 +1,62 @@ +package com.xayah.databackup.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface MediaDao { + @Upsert(entity = MediaBackupEntity::class) + suspend fun upsertBackup(item: MediaBackupEntity) + + @Upsert(entity = MediaRestoreEntity::class) + suspend fun upsertRestore(item: MediaRestoreEntity) + + @Upsert(entity = MediaBackupOperationEntity::class) + suspend fun upsertBackupOp(item: MediaBackupOperationEntity): Long + + @Upsert(entity = MediaRestoreOperationEntity::class) + suspend fun upsertRestoreOp(item: MediaRestoreOperationEntity): Long + + @Upsert(entity = MediaBackupEntity::class) + suspend fun upsertBackup(items: List) + + @Upsert(entity = MediaRestoreEntity::class) + suspend fun upsertRestore(items: List) + + @Transaction + @Query("SELECT * FROM MediaBackupEntity") + fun queryAllBackupFlow(): Flow> + + @Transaction + @Query("SELECT * FROM MediaBackupEntity") + suspend fun queryAllBackup(): List + + @Transaction + @Query("SELECT * FROM MediaRestoreEntity WHERE timestamp = :timestamp") + fun queryAllRestoreFlow(timestamp: Long): Flow> + + @Query("SELECT * FROM MediaBackupEntity WHERE selected = 1") + suspend fun queryBackupSelected(): List + + @Query("SELECT * FROM MediaRestoreEntity WHERE selected = 1 AND timestamp = :timestamp") + suspend fun queryRestoreSelected(timestamp: Long): List + + @Query("SELECT DISTINCT timestamp FROM MediaRestoreEntity") + suspend fun queryTimestamps(): List + + @Query("SELECT COUNT(*) FROM MediaBackupEntity WHERE selected = 1") + fun countBackupSelected(): Flow + + @Query("SELECT COUNT(*) FROM MediaRestoreEntity WHERE selected = 1 AND timestamp = :timestamp") + fun countRestoreSelected(timestamp: Long): Flow + + @Delete(entity = MediaBackupEntity::class) + suspend fun deleteBackup(item: MediaBackupEntity) + + @Delete(entity = MediaRestoreEntity::class) + suspend fun deleteRestore(item: MediaRestoreEntity) +} diff --git a/source/app/src/main/java/com/xayah/databackup/data/MediaEntity.kt b/source/app/src/main/java/com/xayah/databackup/data/MediaEntity.kt new file mode 100644 index 0000000000..bbabee8445 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/data/MediaEntity.kt @@ -0,0 +1,111 @@ +package com.xayah.databackup.data + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.xayah.databackup.util.DataType +import com.xayah.databackup.util.command.MediumRestoreUtil + +@Entity +data class MediaBackupEntity( + @PrimaryKey var path: String, + var name: String, + @ColumnInfo(defaultValue = "0") var sizeBytes: Long, + @ColumnInfo(defaultValue = "1") var selected: Boolean, +) { + val sizeDisplay: String + get() = formatSize(sizeBytes.toDouble()) +} + +@Entity +data class MediaBackupEntityUpsert( + @PrimaryKey var path: String, + var name: String, +) + +@Entity +data class MediaRestoreEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + var timestamp: Long, + var path: String, + var name: String, + var sizeBytes: Long, + var selected: Boolean, +) { + val archivePath: String + get() = "${MediumRestoreUtil.getMediaItemSavePath(name, timestamp)}/${DataType.MEDIA_MEDIA.type}.${MediumRestoreUtil.compressionType.suffix}" + + val sizeDisplay: String + get() = formatSize(sizeBytes.toDouble()) +} + +@Entity( + foreignKeys = [ForeignKey( + entity = MediaBackupEntity::class, + parentColumns = arrayOf("path"), + childColumns = arrayOf("path"), + onDelete = ForeignKey.CASCADE + )] +) +data class MediaBackupOperationEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + var timestamp: Long, + var startTimestamp: Long, + var endTimestamp: Long, + var path: String, + var name: String, + var opLog: String, + var opState: OperationState = OperationState.IDLE, + var state: Boolean = false, +) { + val isSucceed: Boolean + get() { + if (opState == OperationState.ERROR) return false + return true + } +} + +@Entity( + foreignKeys = [ForeignKey( + entity = MediaRestoreEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("entityId"), + onDelete = ForeignKey.CASCADE + )] +) +data class MediaRestoreOperationEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + @ColumnInfo(index = true) var entityId: Long, + var timestamp: Long, + var startTimestamp: Long, + var endTimestamp: Long, + var path: String, + var name: String, + var opLog: String, + var opState: OperationState = OperationState.IDLE, + var state: Boolean = false, +) { + val archivePath: String + get() = "${MediumRestoreUtil.getMediaItemSavePath(name, timestamp)}/${DataType.MEDIA_MEDIA.type}.${MediumRestoreUtil.compressionType.suffix}" + + val isSucceed: Boolean + get() { + if (opState == OperationState.ERROR) return false + return true + } +} + +@Entity +data class MediaBackupWithOpEntity( + @Embedded val media: MediaBackupEntity, + @Relation(parentColumn = "path", entityColumn = "path") val opList: List, +) + +@Entity +data class MediaRestoreWithOpEntity( + @Embedded val media: MediaRestoreEntity, + @Relation(parentColumn = "id", entityColumn = "entityId") val opList: List, +) diff --git a/source/app/src/main/java/com/xayah/databackup/data/PackageBackupEntireEntity.kt b/source/app/src/main/java/com/xayah/databackup/data/PackageBackupEntireEntity.kt index 27b61db806..b2455d490f 100644 --- a/source/app/src/main/java/com/xayah/databackup/data/PackageBackupEntireEntity.kt +++ b/source/app/src/main/java/com/xayah/databackup/data/PackageBackupEntireEntity.kt @@ -30,7 +30,7 @@ data class StorageStats( var externalCacheBytes: Long = 0, ) -private fun formatSize(sizeBytes: Double): String = run { +fun formatSize(sizeBytes: Double): String = run { var unit = "Bytes" var size = sizeBytes val gb = (1000 * 1000 * 1000).toDouble() diff --git a/source/app/src/main/java/com/xayah/databackup/module/DatabaseModule.kt b/source/app/src/main/java/com/xayah/databackup/module/DatabaseModule.kt index ed42b6e25a..811f4b4593 100644 --- a/source/app/src/main/java/com/xayah/databackup/module/DatabaseModule.kt +++ b/source/app/src/main/java/com/xayah/databackup/module/DatabaseModule.kt @@ -3,7 +3,9 @@ package com.xayah.databackup.module import android.content.Context import androidx.room.Room import com.xayah.databackup.data.AppDatabase +import com.xayah.databackup.data.DirectoryDao import com.xayah.databackup.data.LogDao +import com.xayah.databackup.data.MediaDao import com.xayah.databackup.data.PackageBackupEntireDao import com.xayah.databackup.data.PackageBackupOperationDao import com.xayah.databackup.data.PackageRestoreEntireDao @@ -42,4 +44,12 @@ object DatabaseModule { @Provides @Singleton fun providePackageRestoreOperationDao(database: AppDatabase): PackageRestoreOperationDao = database.packageRestoreOperationDao() + + @Provides + @Singleton + fun provideDirectoryDao(database: AppDatabase): DirectoryDao = database.directoryDao() + + @Provides + @Singleton + fun provideMediaDao(database: AppDatabase): MediaDao = database.mediaDao() } diff --git a/source/app/src/main/java/com/xayah/databackup/service/OperationLocalService.kt b/source/app/src/main/java/com/xayah/databackup/service/OperationLocalService.kt index bf7ded476a..13c9cec9e7 100644 --- a/source/app/src/main/java/com/xayah/databackup/service/OperationLocalService.kt +++ b/source/app/src/main/java/com/xayah/databackup/service/OperationLocalService.kt @@ -88,4 +88,6 @@ class OperationLocalService(private val context: Context) { suspend fun backupPackagesAfterwards(preparation: BackupPreparation) = getService().backupPackagesAfterwards(preparation) suspend fun restorePackagesPreparation() = getService().restorePackagesPreparation() suspend fun restorePackages(timestamp: Long) = getService().restorePackages(timestamp) + suspend fun backupMedium(timestamp: Long) = getService().backupMedium(timestamp) + suspend fun restoreMedium(timestamp: Long) = getService().restoreMedium(timestamp) } diff --git a/source/app/src/main/java/com/xayah/databackup/service/OperationLocalServiceImpl.kt b/source/app/src/main/java/com/xayah/databackup/service/OperationLocalServiceImpl.kt index e82dcbd590..bdcfe1842e 100644 --- a/source/app/src/main/java/com/xayah/databackup/service/OperationLocalServiceImpl.kt +++ b/source/app/src/main/java/com/xayah/databackup/service/OperationLocalServiceImpl.kt @@ -4,6 +4,10 @@ import android.app.Service import android.content.Intent import android.os.Binder import android.os.IBinder +import com.xayah.databackup.data.MediaBackupOperationEntity +import com.xayah.databackup.data.MediaDao +import com.xayah.databackup.data.MediaRestoreEntity +import com.xayah.databackup.data.MediaRestoreOperationEntity import com.xayah.databackup.data.OperationMask import com.xayah.databackup.data.OperationState import com.xayah.databackup.data.PackageBackupEntireDao @@ -18,8 +22,10 @@ import com.xayah.databackup.util.DateUtil import com.xayah.databackup.util.GsonUtil import com.xayah.databackup.util.LogUtil import com.xayah.databackup.util.PathUtil -import com.xayah.databackup.util.command.OperationBackupUtil -import com.xayah.databackup.util.command.OperationRestoreUtil +import com.xayah.databackup.util.command.MediumBackupUtil +import com.xayah.databackup.util.command.MediumRestoreUtil +import com.xayah.databackup.util.command.PackagesBackupUtil +import com.xayah.databackup.util.command.PackagesRestoreUtil import com.xayah.databackup.util.command.PreparationUtil import com.xayah.databackup.util.iconPath import com.xayah.databackup.util.readBackupItself @@ -70,6 +76,9 @@ class OperationLocalServiceImpl : Service() { @Inject lateinit var gsonUtil: GsonUtil + @Inject + lateinit var mediaDao: MediaDao + suspend fun backupPackagesPreparation(): BackupPreparation = withIOContext { mutex.withLock { val logTag = "Packages backup preparation" @@ -93,7 +102,7 @@ class OperationLocalServiceImpl : Service() { val logTag = "Packages backup" val context = applicationContext val remoteRootService = RemoteRootService(context) - val operationBackupUtil = OperationBackupUtil(context, timestamp, logUtil, remoteRootService, packageBackupOperationDao, gsonUtil) + val packagesBackupUtil = PackagesBackupUtil(context, timestamp, logUtil, remoteRootService, packageBackupOperationDao, gsonUtil) logUtil.log(logTag, "Started.") val packages = packageBackupEntireDao.queryActiveTotalPackages() @@ -104,7 +113,7 @@ class OperationLocalServiceImpl : Service() { logUtil.log(logTag, "Current package: $packageName") logUtil.log(logTag, "isApkSelected: $isApkSelected") logUtil.log(logTag, "isDataSelected: $isDataSelected") - remoteRootService.mkdirs(operationBackupUtil.getPackageItemSavePath(packageName)) + remoteRootService.mkdirs(packagesBackupUtil.getPackageItemSavePath(packageName)) val packageBackupOperation = PackageBackupOperation( @@ -118,12 +127,12 @@ class OperationLocalServiceImpl : Service() { } if (isApkSelected) { - operationBackupUtil.backupApk(packageBackupOperation, packageName) + packagesBackupUtil.backupApk(packageBackupOperation, packageName) } else { packageBackupOperation.apkState = OperationState.SKIP } if (isDataSelected) { - operationBackupUtil.apply { + packagesBackupUtil.apply { backupData(packageBackupOperation, packageName, DataType.PACKAGE_USER) backupData(packageBackupOperation, packageName, DataType.PACKAGE_USER_DE) backupData(packageBackupOperation, packageName, DataType.PACKAGE_DATA) @@ -167,7 +176,7 @@ class OperationLocalServiceImpl : Service() { packageRestoreEntireDao.upsert(restoreEntire) // Save config - operationBackupUtil.backupConfig(restoreEntire, DataType.PACKAGE_CONFIG) + packagesBackupUtil.backupConfig(restoreEntire, DataType.PACKAGE_CONFIG) // Reset selected items if enabled. if (context.readResetBackupList()) { @@ -254,7 +263,7 @@ class OperationLocalServiceImpl : Service() { val logTag = "Packages restore" val context = applicationContext val remoteRootService = RemoteRootService(context) - val operationRestoreUtil = OperationRestoreUtil(context, logUtil, remoteRootService, packageRestoreOperationDao) + val packagesRestoreUtil = PackagesRestoreUtil(context, logUtil, remoteRootService, packageRestoreOperationDao) logUtil.log(logTag, "Started.") val packages = packageRestoreEntireDao.queryActiveTotalPackages() @@ -280,12 +289,12 @@ class OperationLocalServiceImpl : Service() { } if (isApkSelected) { - operationRestoreUtil.restoreApk(packageRestoreOperation, packageName, packageTimestamp, compressionType) + packagesRestoreUtil.restoreApk(packageRestoreOperation, packageName, packageTimestamp, compressionType) } else { - operationRestoreUtil.queryInstalled(packageRestoreOperation, packageName) + packagesRestoreUtil.queryInstalled(packageRestoreOperation, packageName) } if (isDataSelected) { - operationRestoreUtil.apply { + packagesRestoreUtil.apply { restoreData(packageRestoreOperation, packageName, packageTimestamp, compressionType, DataType.PACKAGE_USER) restoreData(packageRestoreOperation, packageName, packageTimestamp, compressionType, DataType.PACKAGE_USER_DE) restoreData(packageRestoreOperation, packageName, packageTimestamp, compressionType, DataType.PACKAGE_DATA) @@ -323,4 +332,121 @@ class OperationLocalServiceImpl : Service() { remoteRootService.destroyService() } } + + suspend fun backupMedium(timestamp: Long) = withIOContext { + mutex.withLock { + val logTag = "Media backup" + val context = applicationContext + val remoteRootService = RemoteRootService(context) + val mediumBackupUtil = MediumBackupUtil(context, timestamp, logUtil, remoteRootService, mediaDao, gsonUtil) + + logUtil.log(logTag, "Started.") + val medium = mediaDao.queryBackupSelected() + medium.forEach { current -> + val name = current.name + val path = current.path + logUtil.log(logTag, "Current media: ${name}: $path") + remoteRootService.mkdirs(mediumBackupUtil.getMediaItemSavePath(name)) + + val mediaBackupOperationEntity = MediaBackupOperationEntity( + id = 0, + timestamp = timestamp, + startTimestamp = DateUtil.getTimestamp(), + endTimestamp = 0, + path = path, + name = name, + opLog = "", + opState = OperationState.IDLE, + state = false, + ).also { entity -> + entity.id = mediaDao.upsertBackupOp(entity) + } + + mediumBackupUtil.backupMedia(mediaBackupOperationEntity) + + // Update package state and end time. + if (mediaBackupOperationEntity.isSucceed) { + logUtil.log(logTag, "Backup succeed.") + } else { + logUtil.log(logTag, "Backup failed.") + } + mediaBackupOperationEntity.state = mediaBackupOperationEntity.isSucceed + mediaBackupOperationEntity.endTimestamp = DateUtil.getTimestamp() + mediaDao.upsertBackupOp(mediaBackupOperationEntity) + + // Insert restore config into database. + if (mediaBackupOperationEntity.isSucceed) { + val restoreEntire = MediaRestoreEntity( + id = 0, + timestamp = timestamp, + path = path, + name = name, + sizeBytes = 0, + selected = true, + ) + mediaDao.upsertRestore(restoreEntire) + + // Save config + mediumBackupUtil.backupConfig(restoreEntire, DataType.MEDIA_CONFIG) + + // Reset selected items if enabled. + if (context.readResetBackupList()) { + current.selected = false + mediaDao.upsertBackup(current) + } + } + } + + context.saveLastBackupTime(timestamp) + remoteRootService.destroyService() + } + } + + suspend fun restoreMedium(timestamp: Long) = withIOContext { + mutex.withLock { + val logTag = "Media restore" + val context = applicationContext + val remoteRootService = RemoteRootService(context) + val mediumRestoreUtil = MediumRestoreUtil(context, logUtil, remoteRootService, mediaDao) + + logUtil.log(logTag, "Started.") + val medium = mediaDao.queryRestoreSelected(timestamp) + medium.forEach { current -> + val name = current.name + val path = current.path + logUtil.log(logTag, "Current media: ${name}: $path") + + val mediaRestoreOperationEntity = MediaRestoreOperationEntity( + id = 0, + entityId = current.id, + timestamp = timestamp, + startTimestamp = DateUtil.getTimestamp(), + endTimestamp = 0, + path = path, + name = name, + opLog = "", + opState = OperationState.IDLE, + state = false, + ).also { entity -> + entity.id = mediaDao.upsertRestoreOp(entity) + } + + mediumRestoreUtil.restoreMedia(entity = mediaRestoreOperationEntity) + + // Update package state and end time. + if (mediaRestoreOperationEntity.isSucceed) { + logUtil.log(logTag, "Restoring succeed.") + } else { + logUtil.log(logTag, "Restoring failed.") + } + mediaRestoreOperationEntity.state = mediaRestoreOperationEntity.isSucceed + mediaRestoreOperationEntity.endTimestamp = DateUtil.getTimestamp() + mediaDao.upsertRestoreOp(mediaRestoreOperationEntity) + } + + context.saveLastRestoringTime(timestamp) + remoteRootService.destroyService() + } + } + } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/DirectoryActivity.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/DirectoryActivity.kt new file mode 100644 index 0000000000..c8a608ceb6 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/DirectoryActivity.kt @@ -0,0 +1,37 @@ +package com.xayah.databackup.ui.activity.directory + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.CompositionLocalProvider +import androidx.core.view.WindowCompat +import com.xayah.databackup.ui.activity.directory.router.DirectoryNavHost +import com.xayah.databackup.ui.activity.directory.router.DirectoryRoutes +import com.xayah.databackup.ui.component.LocalSlotScope +import com.xayah.databackup.ui.component.rememberSlotScope +import com.xayah.databackup.ui.theme.DataBackupTheme +import com.xayah.databackup.util.IntentUtil +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class DirectoryActivity : ComponentActivity() { + @ExperimentalFoundationApi + @ExperimentalMaterial3Api + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + + val route = intent.getStringExtra(IntentUtil.ExtraRoute) ?: DirectoryRoutes.DirectoryBackup.route + setContent { + DataBackupTheme { + val slotScope = rememberSlotScope() + + CompositionLocalProvider(LocalSlotScope provides slotScope) { + DirectoryNavHost(route) + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/page/Directory.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/page/Directory.kt new file mode 100644 index 0000000000..e4aa61ddd4 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/page/Directory.kt @@ -0,0 +1,84 @@ +package com.xayah.databackup.ui.activity.directory.page + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import com.xayah.databackup.data.DirectoryType +import com.xayah.databackup.ui.component.DirectoryScaffold +import com.xayah.databackup.ui.component.ListItemDirectory +import com.xayah.databackup.ui.component.Loader +import com.xayah.databackup.ui.component.Serial +import com.xayah.databackup.ui.component.paddingBottom +import com.xayah.databackup.ui.component.paddingHorizontal +import com.xayah.databackup.ui.component.paddingTop +import com.xayah.databackup.ui.token.CommonTokens +import com.xayah.librootservice.util.withIOContext +import kotlinx.coroutines.launch + +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun PageDirectory(title: String, directoryType: DirectoryType) { + val context = LocalContext.current + val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() + val uiState by viewModel.uiState + val directories by uiState.directories.collectAsState(initial = listOf()) + + LaunchedEffect(null) { + viewModel.initialize(context = context, directoryType = directoryType) + } + + DirectoryScaffold( + title = title, + onFabClick = { + viewModel.onAdd(context = (context as ComponentActivity)) + } + ) { + Loader(modifier = Modifier.fillMaxSize(), isLoading = uiState.isLoading) { + LazyColumn( + modifier = Modifier.paddingHorizontal(CommonTokens.PaddingMedium), + verticalArrangement = Arrangement.spacedBy(CommonTokens.PaddingMedium) + ) { + item { + Spacer(modifier = Modifier.paddingTop(CommonTokens.PaddingMedium)) + } + + items(items = directories, key = { it.id }) { item -> + ListItemDirectory( + entity = item, + onCardClick = { + scope.launch { + withIOContext { + viewModel.select(context = context, path = item.path, type = directoryType, id = item.id) + } + } + }, + chipGroup = { + for (tag in item.tags) { + if (tag.isNotEmpty()) Serial(serial = tag, enabled = item.enabled) + } + } + ) + } + + item { + Spacer(modifier = Modifier.paddingBottom(CommonTokens.PaddingMedium)) + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/page/DirectoryViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/page/DirectoryViewModel.kt new file mode 100644 index 0000000000..5b55241e1c --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/page/DirectoryViewModel.kt @@ -0,0 +1,201 @@ +package com.xayah.databackup.ui.activity.directory.page + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xayah.databackup.R +import com.xayah.databackup.data.DirectoryDao +import com.xayah.databackup.data.DirectoryEntity +import com.xayah.databackup.data.DirectoryType +import com.xayah.databackup.data.DirectoryUpsertEntity +import com.xayah.databackup.data.StorageType +import com.xayah.databackup.util.ConstantUtil +import com.xayah.databackup.util.command.PreparationUtil +import com.xayah.databackup.util.command.toSpaceString +import com.xayah.databackup.util.saveBackupSavePath +import com.xayah.databackup.util.saveRestoreSavePath +import com.xayah.libpickyou.ui.PickYouLauncher +import com.xayah.libpickyou.ui.activity.PickerType +import com.xayah.librootservice.service.RemoteRootService +import com.xayah.librootservice.util.ExceptionUtil +import com.xayah.librootservice.util.ExceptionUtil.tryOnScope +import com.xayah.librootservice.util.withIOContext +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import java.nio.file.Paths +import javax.inject.Inject +import kotlin.io.path.name +import kotlin.io.path.pathString + +data class DirectoryUiState( + val isLoading: Boolean, + val directoryType: DirectoryType, + val directoryDao: DirectoryDao, +) { + val directories: Flow> = directoryDao.queryActiveDirectoriesFlow().distinctUntilChanged() +} + +@HiltViewModel +class DirectoryViewModel @Inject constructor(private val directoryDao: DirectoryDao) : ViewModel() { + private val _uiState = mutableStateOf(DirectoryUiState(isLoading = true, directoryType = DirectoryType.BACKUP, directoryDao = directoryDao)) + val uiState: State + get() = _uiState + + private suspend fun queryId(parent: String, child: String, type: DirectoryType) = directoryDao.queryId(parent, child, type) + private suspend fun inactivateDirectories() = directoryDao.updateActive(active = false) + private suspend fun activateDirectories() = directoryDao.updateActive(type = uiState.value.directoryType, excludeType = StorageType.EXTERNAL, active = true) + private suspend fun querySelectedByDirectoryType() = directoryDao.querySelectedByDirectoryType(type = uiState.value.directoryType) + suspend fun select(context: Context, path: String, type: DirectoryType, id: Long) = run { + when (uiState.value.directoryType) { + DirectoryType.BACKUP -> context.saveBackupSavePath(path) + DirectoryType.RESTORE -> context.saveRestoreSavePath(path) + } + + directoryDao.select(type, id) + } + + suspend fun reset(context: Context) = run { + select( + context = context, + path = "${ConstantUtil.DefaultPathParent}/${ConstantUtil.DefaultPathChild}", + type = uiState.value.directoryType, + id = when (uiState.value.directoryType) { + DirectoryType.BACKUP -> 1 + DirectoryType.RESTORE -> 2 + } + ) + } + + private suspend fun queryActiveDirectories() = directoryDao.queryActiveDirectories() + private suspend fun upsert(items: List) = directoryDao.upsert(items) + private suspend fun upsert(item: DirectoryEntity) = directoryDao.upsert(item) + suspend fun delete(item: DirectoryEntity) = directoryDao.delete(item) + + suspend fun initialize(context: Context, directoryType: DirectoryType) { + withIOContext { + // Set directory type + _uiState.value = uiState.value.copy(directoryType = directoryType) + + // Inactivate all directories + inactivateDirectories() + + // Internal storage + val internalDirs = mutableListOf() + val internalDirectory = DirectoryUpsertEntity( + id = 1, + title = context.getString(R.string.internal_storage), + parent = ConstantUtil.DefaultPathParent, + child = ConstantUtil.DefaultPathChild, + directoryType = DirectoryType.BACKUP, + storageType = StorageType.INTERNAL, + ) + internalDirs.add(internalDirectory) + internalDirs.add(internalDirectory.copy(id = 2, directoryType = DirectoryType.RESTORE)) + upsert(internalDirs) + + // External storage + val externalList = PreparationUtil.listExternalStorage() + val externalDirs = mutableListOf() + for (storageItem in externalList) { + // e.g. /mnt/media_rw/E7F9-FA61 + tryOnScope { + val child = ConstantUtil.DefaultPathChild + externalDirs.add( + DirectoryUpsertEntity( + id = queryId(parent = storageItem, child = child, directoryType), + title = context.getString(R.string.external_storage), + parent = storageItem, + child = child, + directoryType = directoryType, + storageType = StorageType.EXTERNAL, + active = true, + ) + ) + } + } + upsert(externalDirs) + + // Activate backup/restore directories except external directories + activateDirectories() + + // Read statFs of each storage + queryActiveDirectories().forEach { entity -> + val parent = entity.parent + ExceptionUtil.tryService(onFailed = { msg -> + entity.error = "${context.getString(R.string.fetch_failed)}: $msg\n${context.getString(R.string.remote_service_err_info)}" + }) { + entity.error = "" + val remoteRootService = RemoteRootService(context) + val statFs = remoteRootService.readStatFs(parent) + entity.availableBytes = statFs.availableBytes + entity.totalBytes = statFs.totalBytes + remoteRootService.destroyService() + } + if (entity.storageType == StorageType.EXTERNAL) { + val tags = mutableListOf() + val type = PreparationUtil.getExternalStorageType(parent) + tags.add(type) + // Check the format + val supported = type.lowercase() in ConstantUtil.SupportedExternalStorageFormat + if (supported.not()) { + tags.add(context.getString(R.string.unsupported_format)) + entity.error = "${context.getString(R.string.supported_format)}: ${ConstantUtil.SupportedExternalStorageFormat.toSpaceString()}" + entity.enabled = false + } else { + entity.error = "" + entity.enabled = true + } + entity.tags = tags + } + + upsert(entity) + } + + val selectedDirectory = querySelectedByDirectoryType() + if (selectedDirectory == null || (selectedDirectory.storageType == StorageType.EXTERNAL && selectedDirectory.enabled.not()) || selectedDirectory.active.not()) + reset(context = context) + _uiState.value = uiState.value.copy(isLoading = false) + } + } + + fun onAdd(context: ComponentActivity) { + PickYouLauncher().apply { + setTitle(context.getString(R.string.select_target_directory)) + setType(PickerType.DIRECTORY) + setLimitation(0) + launch(context) { pathList -> + viewModelScope.launch { + withIOContext { + val customDirList = mutableListOf() + pathList.forEach { pathString -> + if (pathString.isNotEmpty()) { + val path = Paths.get(pathString) + val parent = path.parent.pathString + val child = path.name + + // Custom storage + val dir = DirectoryUpsertEntity( + id = queryId(parent = parent, child = child, type = uiState.value.directoryType), + title = context.getString(R.string.custom_storage), + parent = parent, + child = child, + directoryType = uiState.value.directoryType, + storageType = StorageType.CUSTOM, + ) + customDirList.add(dir) + } + } + + upsert(customDirList) + initialize(context, uiState.value.directoryType) + } + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/router/NavHost.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/router/NavHost.kt new file mode 100644 index 0000000000..28216fea3b --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/router/NavHost.kt @@ -0,0 +1,31 @@ +package com.xayah.databackup.ui.activity.directory.router + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.xayah.databackup.R +import com.xayah.databackup.data.DirectoryType +import com.xayah.databackup.ui.activity.directory.page.PageDirectory +import com.xayah.databackup.ui.component.LocalSlotScope + + +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun DirectoryNavHost(startDestination: String) { + val navController = LocalSlotScope.current!!.navController + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(DirectoryRoutes.DirectoryBackup.route) { + PageDirectory(title = stringResource(id = R.string.backup_dir), directoryType = DirectoryType.BACKUP) + } + composable(DirectoryRoutes.DirectoryRestore.route) { + PageDirectory(title = stringResource(id = R.string.restore_dir), directoryType = DirectoryType.RESTORE) + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/router/Routes.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/router/Routes.kt new file mode 100644 index 0000000000..42a981fd39 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/directory/router/Routes.kt @@ -0,0 +1,6 @@ +package com.xayah.databackup.ui.activity.directory.router + +sealed class DirectoryRoutes(val route: String) { + object DirectoryBackup : DirectoryRoutes(route = "directory_backup") + object DirectoryRestore : DirectoryRoutes(route = "directory_restore") +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/guide/page/env/Env.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/guide/page/env/Env.kt index 2ff4b82d1f..7410348578 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/guide/page/env/Env.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/guide/page/env/Env.kt @@ -28,15 +28,21 @@ import com.xayah.databackup.ui.component.paddingTop import com.xayah.databackup.ui.token.CommonTokens import com.xayah.databackup.ui.token.State import com.xayah.databackup.util.command.EnvUtil +import com.xayah.databackup.util.command.PreparationUtil import com.xayah.databackup.util.saveAppVersionName import com.xayah.librootservice.util.withIOContext import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @ExperimentalMaterial3Api @Composable fun PageEnv(viewModel: GuideViewModel) { val context = LocalContext.current val scope = rememberCoroutineScope() + val mutex = remember { + Mutex() + } val contents = listOf( stringResource(id = R.string.grant_root_access), stringResource(id = R.string.release_prebuilt_binaries), @@ -71,6 +77,9 @@ fun PageEnv(viewModel: GuideViewModel) { val statesList = states.value.toMutableList() statesList[0] = if (Shell.getShell().isRoot) State.Succeed else State.Failed states.value = statesList.toList() + + // Kill daemon + PreparationUtil.killDaemon(context) } } }, @@ -112,7 +121,9 @@ fun PageEnv(viewModel: GuideViewModel) { state = states.value[it], onClick = { scope.launch { - onClicks[it]() + mutex.withLock { + onClicks[it]() + } } }) } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/MainViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/MainViewModel.kt index cfc8f07d9c..c9992d6e35 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/MainViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/MainViewModel.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.vector.ImageVector @@ -26,6 +27,7 @@ import com.xayah.databackup.ui.component.TreeTopBar import com.xayah.databackup.ui.component.openFileOpDialog import com.xayah.databackup.util.DateUtil import com.xayah.databackup.util.PathUtil +import com.xayah.databackup.util.command.toLineString import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -68,8 +70,8 @@ sealed class MainUiState( val context = LocalContext.current val scope = rememberCoroutineScope() val dialogSlot = LocalSlotScope.current!!.dialogSlot - val uiState = viewModel.uiState.value - val logText = uiState.logText + val uiState by viewModel.uiState + val logText = uiState.logTextList.toLineString() val selectedIndex = uiState.selectedIndex ExtendedFloatingActionButton( onClick = { diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/backup/Backup.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/backup/Backup.kt index dc74fd791d..6642f96a1b 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/backup/Backup.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/backup/Backup.kt @@ -1,208 +1,46 @@ package com.xayah.databackup.ui.activity.main.page.backup -import android.content.Context import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import com.xayah.databackup.R +import com.xayah.databackup.ui.activity.directory.router.DirectoryRoutes import com.xayah.databackup.ui.activity.main.router.MainRoutes import com.xayah.databackup.ui.activity.operation.router.OperationRoutes import com.xayah.databackup.ui.component.CardActionButton -import com.xayah.databackup.ui.component.DialogState import com.xayah.databackup.ui.component.LocalSlotScope import com.xayah.databackup.ui.component.Module import com.xayah.databackup.ui.component.OverLookBackupCard -import com.xayah.databackup.ui.component.RadioButtonGroup import com.xayah.databackup.ui.component.VerticalGrid import com.xayah.databackup.ui.component.paddingBottom import com.xayah.databackup.ui.component.paddingHorizontal import com.xayah.databackup.ui.component.paddingTop import com.xayah.databackup.ui.token.CommonTokens -import com.xayah.databackup.ui.token.RadioTokens -import com.xayah.databackup.util.ConstantUtil import com.xayah.databackup.util.IntentUtil -import com.xayah.databackup.util.command.PreparationUtil -import com.xayah.databackup.util.readBackupSavePath -import com.xayah.databackup.util.readExternalBackupSaveChild -import com.xayah.databackup.util.readInternalBackupSaveChild -import com.xayah.databackup.util.saveBackupSavePath -import com.xayah.databackup.util.saveExternalBackupSaveChild -import com.xayah.databackup.util.saveInternalBackupSaveChild -import com.xayah.librootservice.service.RemoteRootService -import com.xayah.librootservice.util.ExceptionUtil.tryService import kotlinx.coroutines.launch -enum class StorageItemType { - Internal, - External, - Custom, -} - -data class StorageItem( - var title: String, - var type: StorageItemType, - var progress: Float, - var parent: String, // default: /storage/emulated/0 - var child: String, // default: DataBackup - var display: String, // default: /storage/emulated/0/DataBackup - var enabled: Boolean, -) - -@ExperimentalMaterial3Api -private suspend fun DialogState.openDirectoryDialog(context: Context) { - val items = mutableListOf() - - val (state, item) = open( - initialState = StorageItem( - title = "", - type = StorageItemType.Internal, - progress = 0f, - parent = "", - child = "", - display = "", - enabled = true - ), - title = context.getString(R.string.backup_dir), - icon = ImageVector.vectorResource(context.theme, context.resources, R.drawable.ic_rounded_folder_open), - onLoading = { - val remoteRootService = RemoteRootService(context) - - // Internal storage - val internalParent = ConstantUtil.DefaultBackupParent - val internalChild = context.readInternalBackupSaveChild() - val internalPath = "${internalParent}/${internalChild}" - val internalItem = StorageItem( - title = context.getString(R.string.internal_storage), - type = StorageItemType.Internal, - progress = 0f, - parent = internalParent, - child = internalChild, - display = internalPath, - enabled = true - ) - - tryService(onFailed = { msg -> - internalItem.display = - "${context.getString(R.string.fetch_failed)}: $msg\n${context.getString(R.string.remote_service_err_info)}" - }) { - val internalStatFs = remoteRootService.readStatFs(internalParent) - internalItem.progress = internalStatFs.availableBytes.toFloat() / internalStatFs.totalBytes - } - items.add(internalItem) - - // External storage - val externalList = PreparationUtil.listExternalStorage() - val externalChild = context.readExternalBackupSaveChild() - for (storageItem in externalList) { - // e.g. /mnt/media_rw/E7F9-FA61 exfat - try { - val (parent, type) = storageItem.split(" ") - val externalPath = "${parent}/${externalChild}" - val item = StorageItem( - title = "${context.getString(R.string.external_storage)} $type", - type = StorageItemType.External, - progress = 0f, - parent = parent, - child = externalChild, - display = externalPath, - enabled = true - ) - tryService(onFailed = { msg -> - item.display = - "${context.getString(R.string.fetch_failed)}: $msg\n${context.getString(R.string.remote_service_err_info)}" - }) { - val externalPathStatFs = remoteRootService.readStatFs(parent) - item.progress = externalPathStatFs.availableBytes.toFloat() / externalPathStatFs.totalBytes - } - // Check the format - val supported = type.lowercase() in ConstantUtil.SupportedExternalStorageFormat - if (supported.not()) { - item.title = "${context.getString(R.string.unsupported_format)}: $type" - item.enabled = false - } - items.add(item) - } catch (_: Exception) { - } - } - remoteRootService.destroyService() - }, - block = { uiState -> - var defIndex = items.indexOfFirst { it.display == context.readBackupSavePath() } - if (defIndex == -1) { - // The save path is not in storage items, reset it. - context.saveBackupSavePath(ConstantUtil.DefaultBackupSavePath) - defIndex = 0 - } - RadioButtonGroup( - items = items.toList(), - defSelected = items[defIndex], - itemVerticalArrangement = Arrangement.spacedBy(RadioTokens.ItemVerticalPadding), - onItemClick = { - uiState.value = it - }, - onItemEnabled = { it.enabled } - ) { item -> - Column(verticalArrangement = Arrangement.spacedBy(CommonTokens.PaddingTiny)) { - Text( - text = item.title, - style = MaterialTheme.typography.labelLarge, - ) - LinearProgressIndicator( - modifier = Modifier.clip(CircleShape), - progress = item.progress - ) - Text( - text = item.display, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - ) - if (state) { - when (item.type) { - StorageItemType.Internal -> { - context.saveInternalBackupSaveChild(item.child) - } - - StorageItemType.External -> { - context.saveExternalBackupSaveChild(item.child) - } - - else -> {} - } - context.saveBackupSavePath("${item.parent}/${item.child}") - } -} - @ExperimentalLayoutApi @ExperimentalMaterial3Api @Composable fun PageBackup() { val scope = rememberCoroutineScope() val context = LocalContext.current - val dialogSlot = LocalSlotScope.current!!.dialogSlot val navController = LocalSlotScope.current!!.navController LazyColumn( modifier = Modifier.paddingHorizontal(CommonTokens.PaddingMedium), @@ -227,7 +65,7 @@ fun PageBackup() { ) val onClicks = listOf Unit>( { - dialogSlot.openDirectoryDialog(context) + IntentUtil.toDirectoryActivity(context = context, route = DirectoryRoutes.DirectoryBackup) }, { navController.navigate(MainRoutes.Tree.route) @@ -270,8 +108,11 @@ fun PageBackup() { { IntentUtil.toOperationActivity(context = context, route = OperationRoutes.PackageBackup) }, - {}, - {}) + { + IntentUtil.toOperationActivity(context = context, route = OperationRoutes.MediaBackup) + }, + {} + ) VerticalGrid( columns = 2, count = items.size, diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/Log.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/Log.kt index b77a45e611..a567b9ff89 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/Log.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/Log.kt @@ -2,21 +2,24 @@ package com.xayah.databackup.ui.activity.main.page.log import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import com.xayah.databackup.ui.component.JetbrainsMonoLabelMediumText +import com.xayah.databackup.ui.component.JetbrainsMonoLabelSmallText import com.xayah.databackup.ui.component.Loader +import com.xayah.databackup.ui.component.paddingHorizontal import com.xayah.databackup.ui.token.CommonTokens @Composable fun PageLog(viewModel: LogViewModel) { - val uiState = viewModel.uiState.value - val logText = uiState.logText + val uiState by viewModel.uiState + val logTextList = uiState.logTextList Loader( modifier = Modifier.fillMaxSize(), @@ -25,14 +28,18 @@ fun PageLog(viewModel: LogViewModel) { }, content = { Column(modifier = Modifier.fillMaxSize()) { - SelectionContainer { - JetbrainsMonoLabelMediumText( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()) - .padding(CommonTokens.PaddingMedium), - text = logText, - ) + LazyColumn(modifier = Modifier.horizontalScroll(rememberScrollState())) { + item { + Spacer(modifier = Modifier.height(CommonTokens.PaddingMedium)) + } + + items(items = logTextList) { + JetbrainsMonoLabelSmallText(modifier = Modifier.paddingHorizontal(CommonTokens.PaddingMedium), text = it) + } + + item { + Spacer(modifier = Modifier.height(CommonTokens.PaddingMedium)) + } } } } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/LogViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/LogViewModel.kt index 98414af1bc..caf19e83f3 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/LogViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/log/LogViewModel.kt @@ -1,6 +1,7 @@ package com.xayah.databackup.ui.activity.main.page.log import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.xayah.databackup.data.LogDao @@ -11,14 +12,14 @@ import javax.inject.Inject data class LogUiState( val logDao: LogDao, - val logText: String, + val logTextList: List, val startTimestamps: List, val selectedIndex: Int, ) @HiltViewModel class LogViewModel @Inject constructor(logDao: LogDao) : ViewModel() { - private val _uiState = mutableStateOf(LogUiState(logDao = logDao, logText = "", startTimestamps = listOf(), selectedIndex = 0)) + private val _uiState = mutableStateOf(LogUiState(logDao = logDao, logTextList = listOf(), startTimestamps = listOf(), selectedIndex = 0)) val uiState: State get() = _uiState @@ -32,28 +33,28 @@ class LogViewModel @Inject constructor(logDao: LogDao) : ViewModel() { } private suspend fun setLogText() = withIOContext { - val uiState = uiState.value + val uiState by uiState val dao = uiState.logDao val startTimestamps = uiState.startTimestamps val index = uiState.selectedIndex if (index != -1) { val logCmdItems = dao.queryLogCmdItems(startTimestamps[index]) - var logText = "" + val logTextList = mutableListOf() logCmdItems.forEach { logCmdEntity -> - logText += "${DateUtil.formatTimestamp(logCmdEntity.log.startTimestamp)} ${logCmdEntity.log.tag}: ${logCmdEntity.log.msg}\n" + logTextList.add("${DateUtil.formatTimestamp(logCmdEntity.log.startTimestamp)} ${logCmdEntity.log.tag}: ${logCmdEntity.log.msg}") logCmdEntity.cmdList.forEach { cmdEntity -> - logText += "${DateUtil.formatTimestamp(cmdEntity.timestamp)} ${cmdEntity.type.name}: ${cmdEntity.msg}\n" + logTextList.add("${DateUtil.formatTimestamp(cmdEntity.timestamp)} ${cmdEntity.type.name}: ${cmdEntity.msg}") } - logText += "\n" + logTextList.add("") } - _uiState.value = uiState.copy(logText = logText) + _uiState.value = uiState.copy(logTextList = logTextList) } else { - _uiState.value = uiState.copy(logText = "") + _uiState.value = uiState.copy(logTextList = listOf()) } } suspend fun initializeUiState() = withIOContext { - val uiState = uiState.value + val uiState by uiState val dao = uiState.logDao val startTimestamps = dao.queryLogStartTimestamps() setStartTimestamps(startTimestamps) @@ -61,7 +62,7 @@ class LogViewModel @Inject constructor(logDao: LogDao) : ViewModel() { } suspend fun deleteCurrentLog() = withIOContext { - val uiState = uiState.value + val uiState by uiState val selectedIndex = uiState.selectedIndex if (selectedIndex == -1) return@withIOContext val startTimestamp = uiState.startTimestamps[selectedIndex] diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/restore/Restore.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/restore/Restore.kt index 6af19168b5..bd252ccf11 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/restore/Restore.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/restore/Restore.kt @@ -3,28 +3,23 @@ package com.xayah.databackup.ui.activity.main.page.restore import android.content.Context import android.content.pm.PackageInfo import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -35,8 +30,7 @@ import com.xayah.databackup.R import com.xayah.databackup.data.OperationMask import com.xayah.databackup.data.PackageRestoreEntire import com.xayah.databackup.data.PackageRestoreEntireDao -import com.xayah.databackup.ui.activity.main.page.backup.StorageItem -import com.xayah.databackup.ui.activity.main.page.backup.StorageItemType +import com.xayah.databackup.ui.activity.directory.router.DirectoryRoutes import com.xayah.databackup.ui.activity.main.router.MainRoutes import com.xayah.databackup.ui.activity.operation.router.OperationRoutes import com.xayah.databackup.ui.component.CardActionButton @@ -44,16 +38,13 @@ import com.xayah.databackup.ui.component.DialogState import com.xayah.databackup.ui.component.LocalSlotScope import com.xayah.databackup.ui.component.Module import com.xayah.databackup.ui.component.OverLookRestoreCard -import com.xayah.databackup.ui.component.RadioButtonGroup import com.xayah.databackup.ui.component.VerticalGrid import com.xayah.databackup.ui.component.openConfirmDialog import com.xayah.databackup.ui.component.paddingBottom import com.xayah.databackup.ui.component.paddingHorizontal import com.xayah.databackup.ui.component.paddingTop import com.xayah.databackup.ui.token.CommonTokens -import com.xayah.databackup.ui.token.RadioTokens import com.xayah.databackup.util.CompressionType -import com.xayah.databackup.util.ConstantUtil import com.xayah.databackup.util.DataType import com.xayah.databackup.util.GsonUtil import com.xayah.databackup.util.IntentUtil @@ -61,148 +52,13 @@ import com.xayah.databackup.util.LogUtil import com.xayah.databackup.util.PathUtil import com.xayah.databackup.util.command.EnvUtil import com.xayah.databackup.util.command.InstallationUtil -import com.xayah.databackup.util.command.PreparationUtil import com.xayah.databackup.util.iconPath -import com.xayah.databackup.util.readExternalRestoreSaveChild -import com.xayah.databackup.util.readInternalRestoreSaveChild -import com.xayah.databackup.util.readRestoreSavePath -import com.xayah.databackup.util.saveExternalRestoreSaveChild -import com.xayah.databackup.util.saveInternalRestoreSaveChild -import com.xayah.databackup.util.saveRestoreSavePath import com.xayah.librootservice.parcelables.PathParcelable import com.xayah.librootservice.service.RemoteRootService import com.xayah.librootservice.util.ExceptionUtil.tryOnScope -import com.xayah.librootservice.util.ExceptionUtil.tryService import com.xayah.librootservice.util.withIOContext import kotlinx.coroutines.launch -@ExperimentalMaterial3Api -private suspend fun DialogState.openDirectoryDialog(context: Context) { - val items = mutableListOf() - - val (state, item) = open( - initialState = StorageItem( - title = "", - type = StorageItemType.Internal, - progress = 0f, - parent = "", - child = "", - display = "", - enabled = true - ), - title = context.getString(R.string.restore_dir), - icon = ImageVector.vectorResource(context.theme, context.resources, R.drawable.ic_rounded_folder_open), - onLoading = { - val remoteRootService = RemoteRootService(context) - - // Internal storage - val internalParent = ConstantUtil.DefaultRestoreParent - val internalChild = context.readInternalRestoreSaveChild() - val internalPath = "${internalParent}/${internalChild}" - val internalItem = StorageItem( - title = context.getString(R.string.internal_storage), - type = StorageItemType.Internal, - progress = 0f, - parent = internalParent, - child = internalChild, - display = internalPath, - enabled = true - ) - - tryService(onFailed = { msg -> - internalItem.display = - "${context.getString(R.string.fetch_failed)}: $msg\n${context.getString(R.string.remote_service_err_info)}" - }) { - val internalStatFs = remoteRootService.readStatFs(internalParent) - internalItem.progress = internalStatFs.availableBytes.toFloat() / internalStatFs.totalBytes - } - items.add(internalItem) - - // External storage - val externalList = PreparationUtil.listExternalStorage() - val externalChild = context.readExternalRestoreSaveChild() - for (storageItem in externalList) { - // e.g. /mnt/media_rw/E7F9-FA61 exfat - try { - val (parent, type) = storageItem.split(" ") - val externalPath = "${parent}/${externalChild}" - val item = StorageItem( - title = "${context.getString(R.string.external_storage)} $type", - type = StorageItemType.External, - progress = 0f, - parent = parent, - child = externalChild, - display = externalPath, - enabled = true - ) - tryService(onFailed = { msg -> - item.display = - "${context.getString(R.string.fetch_failed)}: $msg\n${context.getString(R.string.remote_service_err_info)}" - }) { - val externalPathStatFs = remoteRootService.readStatFs(parent) - item.progress = externalPathStatFs.availableBytes.toFloat() / externalPathStatFs.totalBytes - } - // Check the format - val supported = type.lowercase() in ConstantUtil.SupportedExternalStorageFormat - if (supported.not()) { - item.title = "${context.getString(R.string.unsupported_format)}: $type" - item.enabled = false - } - items.add(item) - } catch (_: Exception) { - } - } - remoteRootService.destroyService() - }, - block = { uiState -> - var defIndex = items.indexOfFirst { it.display == context.readRestoreSavePath() } - if (defIndex == -1) { - // The save path is not in storage items, reset it. - context.saveRestoreSavePath(ConstantUtil.DefaultRestoreSavePath) - defIndex = 0 - } - RadioButtonGroup( - items = items.toList(), - defSelected = items[defIndex], - itemVerticalArrangement = Arrangement.spacedBy(RadioTokens.ItemVerticalPadding), - onItemClick = { - uiState.value = it - }, - onItemEnabled = { it.enabled } - ) { item -> - Column(verticalArrangement = Arrangement.spacedBy(CommonTokens.PaddingTiny)) { - Text( - text = item.title, - style = MaterialTheme.typography.labelLarge, - ) - LinearProgressIndicator( - modifier = Modifier.clip(CircleShape), - progress = item.progress - ) - Text( - text = item.display, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - ) - if (state) { - when (item.type) { - StorageItemType.Internal -> { - context.saveInternalRestoreSaveChild(item.child) - } - - StorageItemType.External -> { - context.saveExternalRestoreSaveChild(item.child) - } - - else -> {} - } - context.saveRestoreSavePath("${item.parent}/${item.child}") - } -} - private data class TypedTimestamp( val timestamp: Long, val archivePathList: MutableList, @@ -280,8 +136,8 @@ private suspend fun DialogState.openReloadDialog(context: Context, logUtil: LogU logUtil.log(logTag, "Timestamp: $timestamp") val tmpApkPath = PathUtil.getTmpApkPath(context = context, packageName = packageName) - val tmpConfigPath = PathUtil.getTmpConfigPath(context = context, packageName = packageName, timestamp = timestamp) - val tmpConfigFilePath = PathUtil.getTmpConfigFilePath(context = context, packageName = packageName, timestamp = timestamp) + val tmpConfigPath = PathUtil.getTmpConfigPath(context = context, name = packageName, timestamp = timestamp) + val tmpConfigFilePath = PathUtil.getTmpConfigFilePath(context = context, name = packageName, timestamp = timestamp) remoteRootService.deleteRecursively(tmpApkPath) remoteRootService.deleteRecursively(tmpConfigPath) remoteRootService.mkdirs(tmpApkPath) @@ -423,7 +279,7 @@ fun PageRestore() { } }, { - dialogSlot.openDirectoryDialog(context) + IntentUtil.toDirectoryActivity(context = context, route = DirectoryRoutes.DirectoryRestore) }, { navController.navigate(MainRoutes.Tree.route) @@ -466,7 +322,9 @@ fun PageRestore() { { IntentUtil.toOperationActivity(context = context, route = OperationRoutes.PackageRestore) }, - {}, + { + IntentUtil.toOperationActivity(context = context, route = OperationRoutes.MediaRestore) + }, {} ) VerticalGrid( diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/Tree.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/Tree.kt index 5c532af1d6..856b320c08 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/Tree.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/Tree.kt @@ -8,13 +8,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedCard import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -23,7 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import com.xayah.databackup.R import com.xayah.databackup.ui.component.CommonButton -import com.xayah.databackup.ui.component.JetbrainsMonoLabelMediumText +import com.xayah.databackup.ui.component.JetbrainsMonoLabelSmallText import com.xayah.databackup.ui.component.Loader import com.xayah.databackup.ui.component.LocalSlotScope import com.xayah.databackup.ui.component.TextButton @@ -35,6 +36,7 @@ import com.xayah.databackup.ui.token.CommonTokens import com.xayah.databackup.util.DateUtil import com.xayah.databackup.util.PathUtil import com.xayah.databackup.util.command.CommonUtil.copyToClipboard +import com.xayah.databackup.util.command.toLineString import kotlinx.coroutines.launch @Composable @@ -42,8 +44,8 @@ fun PageTree(viewModel: TreeViewModel) { val context = LocalContext.current val scope = rememberCoroutineScope() val dialogSlot = LocalSlotScope.current!!.dialogSlot - val uiState = viewModel.uiState.value - val treeText = uiState.treeText + val uiState by viewModel.uiState + val treeTextList = uiState.treeTextList Loader( modifier = Modifier.fillMaxSize(), @@ -59,14 +61,18 @@ fun PageTree(viewModel: TreeViewModel) { .paddingTop(CommonTokens.PaddingSmall) .paddingHorizontal(CommonTokens.PaddingSmall) ) { - SelectionContainer { - JetbrainsMonoLabelMediumText( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()) - .padding(CommonTokens.PaddingSmall), - text = treeText, - ) + LazyColumn(modifier = Modifier.horizontalScroll(rememberScrollState())) { + item { + Spacer(modifier = Modifier.height(CommonTokens.PaddingMedium)) + } + + items(items = treeTextList) { + JetbrainsMonoLabelSmallText(modifier = Modifier.paddingHorizontal(CommonTokens.PaddingMedium), text = it) + } + + item { + Spacer(modifier = Modifier.height(CommonTokens.PaddingMedium)) + } } } Row( @@ -77,7 +83,7 @@ fun PageTree(viewModel: TreeViewModel) { horizontalArrangement = Arrangement.End ) { TextButton(text = stringResource(R.string.copy)) { - context.copyToClipboard(treeText) + context.copyToClipboard(treeTextList.toLineString()) Toast.makeText(context, context.getString(R.string.succeed), Toast.LENGTH_SHORT).show() } Spacer(modifier = Modifier.width(CommonTokens.PaddingMedium)) @@ -89,7 +95,7 @@ fun PageTree(viewModel: TreeViewModel) { title = context.getString(R.string.save_directory_structure), filePath = filePath, icon = ImageVector.vectorResource(context.theme, context.resources, R.drawable.ic_rounded_account_tree), - text = treeText + text = treeTextList.toLineString() ) viewModel.setTreeText(context) } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/TreeViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/TreeViewModel.kt index 79594c6508..539adbe530 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/TreeViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/main/page/tree/TreeViewModel.kt @@ -2,6 +2,7 @@ package com.xayah.databackup.ui.activity.main.page.tree import android.content.Context import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.xayah.databackup.R @@ -23,7 +24,7 @@ enum class TreeType { } data class TreeUiState( - val treeText: String, + val treeTextList: List, val typeList: List, val selectedIndex: Int, ) @@ -32,7 +33,7 @@ data class TreeUiState( class TreeViewModel @Inject constructor() : ViewModel() { private val _uiState = mutableStateOf( TreeUiState( - treeText = "", + treeTextList = listOf(), typeList = listOf(TreeType.Simplify, TreeType.Integral), selectedIndex = 0 ) @@ -40,21 +41,21 @@ class TreeViewModel @Inject constructor() : ViewModel() { val uiState: State get() = _uiState - private suspend fun loadTree(context: Context) = withIOContext { - val uiState = uiState.value + private suspend fun loadTree(context: Context): List = withIOContext { + val uiState by uiState when (uiState.typeList[uiState.selectedIndex]) { - TreeType.Simplify -> PreparationUtil.tree(path = context.readBackupSavePath(), exclude = PathUtil.getExcludeDirs()) - TreeType.Integral -> PreparationUtil.tree(path = context.readBackupSavePath()) + TreeType.Simplify -> PreparationUtil.tree(path = context.readBackupSavePath(), exclude = PathUtil.getExcludeDirs()).split("\n") + TreeType.Integral -> PreparationUtil.tree(path = context.readBackupSavePath()).split("\n") } } suspend fun setTreeText(context: Context) { - _uiState.value = uiState.value.copy(treeText = loadTree(context)) + _uiState.value = uiState.value.copy(treeTextList = loadTree(context)) } suspend fun setTreeType(context: Context, type: TreeType) { withIOContext { - val uiState = uiState.value + val uiState by uiState val typeList = uiState.typeList _uiState.value = uiState.copy(selectedIndex = typeList.indexOf(type)) setTreeText(context) diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/backup/List.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/backup/List.kt new file mode 100644 index 0000000000..86bb20e3a2 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/backup/List.kt @@ -0,0 +1,92 @@ +package com.xayah.databackup.ui.activity.operation.page.media.backup + +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.xayah.databackup.R +import com.xayah.databackup.ui.activity.operation.page.packages.backup.confirmExit +import com.xayah.databackup.ui.component.ListItemMediaBackup +import com.xayah.databackup.ui.component.Loader +import com.xayah.databackup.ui.component.LocalSlotScope +import com.xayah.databackup.ui.component.MediaListScaffold +import com.xayah.databackup.ui.component.paddingBottom +import com.xayah.databackup.ui.component.paddingHorizontal +import com.xayah.databackup.ui.component.paddingTop +import com.xayah.databackup.ui.token.CommonTokens +import kotlinx.coroutines.launch + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun PageMediaBackupList() { + val context = LocalContext.current + val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() + val dialogSlot = LocalSlotScope.current!!.dialogSlot + val uiState by viewModel.uiState + val isProcessing = uiState.opType == OpType.PROCESSING + val medium by uiState.medium.collectAsState(initial = listOf()) + val mediumDisplay = if (isProcessing) medium.filter { it.media.selected } else medium + val selectedCount by uiState.selectedCount.collectAsState(initial = 0) + + LaunchedEffect(null) { + viewModel.initialize() + } + + if (isProcessing) BackHandler { + scope.launch { + confirmExit(dialogSlot, context) + } + } + + MediaListScaffold( + title = when (uiState.opType) { + OpType.LIST -> stringResource(id = R.string.backup_list) + OpType.PROCESSING -> stringResource(id = R.string.backing_up) + }, + selectedCount = selectedCount, + opType = uiState.opType, + onFabClick = { + viewModel.onProcessing() + }, + onAddClick = { + viewModel.onAdd(context = (context as ComponentActivity)) + } + ) { + Loader(modifier = Modifier.fillMaxSize(), isLoading = uiState.isLoading) { + LazyColumn( + modifier = Modifier.paddingHorizontal(CommonTokens.PaddingMedium), + verticalArrangement = Arrangement.spacedBy(CommonTokens.PaddingMedium) + ) { + item { + Spacer(modifier = Modifier.paddingTop(CommonTokens.PaddingMedium)) + } + + items(items = mediumDisplay, key = { it.media.path }) { item -> + ListItemMediaBackup(entity = item) + } + + item { + Spacer(modifier = Modifier.paddingBottom(CommonTokens.PaddingMedium)) + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/backup/ListViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/backup/ListViewModel.kt new file mode 100644 index 0000000000..d2036f54dc --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/backup/ListViewModel.kt @@ -0,0 +1,160 @@ +package com.xayah.databackup.ui.activity.operation.page.media.backup + +import android.content.Context +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xayah.databackup.DataBackupApplication +import com.xayah.databackup.R +import com.xayah.databackup.data.MediaBackupEntity +import com.xayah.databackup.data.MediaBackupEntityUpsert +import com.xayah.databackup.data.MediaBackupWithOpEntity +import com.xayah.databackup.data.MediaDao +import com.xayah.databackup.service.OperationLocalService +import com.xayah.databackup.util.ConstantUtil +import com.xayah.databackup.util.DateUtil +import com.xayah.databackup.util.PathUtil +import com.xayah.databackup.util.readBackupSavePath +import com.xayah.libpickyou.ui.PickYouLauncher +import com.xayah.libpickyou.ui.activity.PickerType +import com.xayah.librootservice.service.RemoteRootService +import com.xayah.librootservice.util.withIOContext +import com.xayah.librootservice.util.withMainContext +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +enum class OpType { + LIST, + PROCESSING, +} + +data class MediaBackupListUiState( + val mutex: Mutex, + val isLoading: Boolean, + val opType: OpType, + val timestamp: Long, + val mediaDao: MediaDao, +) { + val medium: Flow> = mediaDao.queryAllBackupFlow().distinctUntilChanged() + val selectedCount: Flow = mediaDao.countBackupSelected().distinctUntilChanged() +} + +@HiltViewModel +class MediaBackupListViewModel @Inject constructor(private val mediaDao: MediaDao) : ViewModel() { + private val _uiState = mutableStateOf( + MediaBackupListUiState( + mutex = Mutex(), + isLoading = true, + opType = OpType.LIST, + timestamp = DateUtil.getTimestamp(), + mediaDao = mediaDao + ) + ) + val uiState: State + get() = _uiState + + suspend fun queryAllBackup() = mediaDao.queryAllBackup() + suspend fun upsertBackup(item: MediaBackupEntity) = mediaDao.upsertBackup(item) + private suspend fun upsertBackup(items: List) = mediaDao.upsertBackup(items) + suspend fun deleteBackup(item: MediaBackupEntity) = mediaDao.deleteBackup(item) + + suspend fun initialize() { + if (uiState.value.opType == OpType.LIST) + withIOContext { + upsertBackup(ConstantUtil.DefaultMediaList.map { (name, path) -> MediaBackupEntityUpsert(path = path, name = name) }) + _uiState.value = uiState.value.copy(isLoading = false) + } + } + + private fun setType(type: OpType) = run { _uiState.value = uiState.value.copy(opType = type) } + private fun updateTimestamp() = run { _uiState.value = uiState.value.copy(timestamp = DateUtil.getTimestamp()) } + + private fun renameDuplicateMedia(name: String): String { + val nameList = name.split("_").toMutableList() + val index = nameList.last().toIntOrNull() + if (index == null) { + nameList.add("0") + } else { + nameList[nameList.lastIndex] = (index + 1).toString() + } + return nameList.joinToString(separator = "_") + } + + fun onAdd(context: ComponentActivity) { + PickYouLauncher().apply { + setTitle(context.getString(R.string.select_target_directory)) + setType(PickerType.DIRECTORY) + setLimitation(0) + launch(context) { pathList -> + viewModelScope.launch { + withIOContext { + val customMediaList = mutableListOf() + pathList.forEach { pathString -> + if (pathString.isNotEmpty() && ConstantUtil.DefaultMediaList.indexOfFirst { it.second == pathString } == -1) { + if (pathString == context.readBackupSavePath()) { + withMainContext { + Toast.makeText(context, context.getString(R.string.backup_dir_as_media_error), Toast.LENGTH_SHORT).show() + } + return@forEach + } + var name = PathUtil.getFileName(pathString) + queryAllBackup().forEach { + if (it.name == name && it.path != pathString) name = renameDuplicateMedia(name) + } + customMediaList.forEach { + if (it.name == name && it.path != pathString) name = renameDuplicateMedia(name) + } + customMediaList.add(MediaBackupEntityUpsert(path = pathString, name = name)) + } + } + upsertBackup(customMediaList) + } + } + } + } + } + + /** + * Clearly tinkering with the itinerary contained within the flow was a bad idea! + * @see https://issuetracker.google.com/issues/291640109#comment9 + */ + suspend fun updateMediaSizeBytes(context: Context, media: MediaBackupEntity) = withIOContext { + val remoteRootService = RemoteRootService(context) + val sizeBytes = remoteRootService.calculateSize(media.path) + if (media.sizeBytes != sizeBytes) { + upsertBackup(media.copy(sizeBytes = sizeBytes)) + } + remoteRootService.destroyService() + } + + fun onProcessing() { + val uiState by uiState + + viewModelScope.launch { + uiState.mutex.withLock { + if (uiState.opType == OpType.LIST) { + withIOContext { + setType(OpType.PROCESSING) + + updateTimestamp() + + val operationLocalService = OperationLocalService(context = DataBackupApplication.application) + operationLocalService.backupMedium(uiState.timestamp) + operationLocalService.destroyService() + + setType(OpType.LIST) + } + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/restore/List.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/restore/List.kt new file mode 100644 index 0000000000..eba42d02c0 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/restore/List.kt @@ -0,0 +1,121 @@ +package com.xayah.databackup.ui.activity.operation.page.media.restore + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.xayah.databackup.R +import com.xayah.databackup.ui.activity.operation.page.media.backup.OpType +import com.xayah.databackup.ui.activity.operation.page.packages.backup.confirmExit +import com.xayah.databackup.ui.component.ChipDropdownMenu +import com.xayah.databackup.ui.component.ListItemMediaRestore +import com.xayah.databackup.ui.component.Loader +import com.xayah.databackup.ui.component.LocalSlotScope +import com.xayah.databackup.ui.component.MediaListScaffold +import com.xayah.databackup.ui.component.ignorePaddingHorizontal +import com.xayah.databackup.ui.component.paddingBottom +import com.xayah.databackup.ui.component.paddingHorizontal +import com.xayah.databackup.ui.token.CommonTokens +import com.xayah.databackup.util.DateUtil +import kotlinx.coroutines.launch + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun PageMediaRestoreList() { + val context = LocalContext.current + val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() + val dialogSlot = LocalSlotScope.current!!.dialogSlot + val uiState by viewModel.uiState + val isProcessing = uiState.opType == OpType.PROCESSING + val medium by uiState.medium.collectAsState(initial = listOf()) + val mediumDisplay = if (isProcessing) medium.filter { it.media.selected } else medium + val selectedCount by uiState.selectedCount.collectAsState(initial = 0) + + LaunchedEffect(null) { + viewModel.initialize() + } + + if (isProcessing) BackHandler { + scope.launch { + confirmExit(dialogSlot, context) + } + } + + MediaListScaffold( + title = when (uiState.opType) { + OpType.LIST -> stringResource(id = R.string.restore_list) + OpType.PROCESSING -> stringResource(id = R.string.restoring) + }, + selectedCount = selectedCount, + opType = uiState.opType, + onFabClick = { + viewModel.onProcessing() + }, + onAddClick = null + ) { + Loader(modifier = Modifier.fillMaxSize(), isLoading = uiState.isLoading) { + LazyColumn( + modifier = Modifier.paddingHorizontal(CommonTokens.PaddingMedium), + verticalArrangement = Arrangement.spacedBy(CommonTokens.PaddingMedium) + ) { + item { + Spacer(modifier = Modifier.height(CommonTokens.PaddingMedium)) + Row( + modifier = Modifier + .ignorePaddingHorizontal(CommonTokens.PaddingMedium) + .horizontalScroll(rememberScrollState()) + .paddingHorizontal(CommonTokens.PaddingMedium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CommonTokens.PaddingMedium) + ) { + val dateList = uiState.timestamps.map { timestamp -> DateUtil.formatTimestamp(timestamp) } + ChipDropdownMenu( + label = stringResource(R.string.date), + trailingIcon = ImageVector.vectorResource(R.drawable.ic_rounded_unfold_more), + defaultSelectedIndex = uiState.selectedIndex, + list = dateList, + onSelected = { index, _ -> + scope.launch { + viewModel.setSelectedIndex(index) + } + }, + onClick = {} + ) + } + } + + items(items = mediumDisplay, key = { it.media.id }) { item -> + ListItemMediaRestore(entity = item) + } + + item { + Spacer(modifier = Modifier.paddingBottom(CommonTokens.PaddingMedium)) + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/restore/ListViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/restore/ListViewModel.kt new file mode 100644 index 0000000000..84d599be44 --- /dev/null +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/media/restore/ListViewModel.kt @@ -0,0 +1,110 @@ +package com.xayah.databackup.ui.activity.operation.page.media.restore + +import android.content.Context +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xayah.databackup.DataBackupApplication +import com.xayah.databackup.data.MediaDao +import com.xayah.databackup.data.MediaRestoreEntity +import com.xayah.databackup.data.MediaRestoreWithOpEntity +import com.xayah.databackup.service.OperationLocalService +import com.xayah.databackup.ui.activity.operation.page.media.backup.OpType +import com.xayah.librootservice.service.RemoteRootService +import com.xayah.librootservice.util.withIOContext +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +data class MediaRestoreListUiState( + val mutex: Mutex, + val isLoading: Boolean, + val opType: OpType, + val timestamps: List, + val selectedIndex: Int, + val mediaDao: MediaDao, +) { + val timestamp: Long + get() = timestamps.getOrElse(selectedIndex) { 0 } + val medium: Flow> = mediaDao.queryAllRestoreFlow(timestamp).distinctUntilChanged() + val selectedCount: Flow = mediaDao.countRestoreSelected(timestamp).distinctUntilChanged() +} + +@HiltViewModel +class MediaRestoreListViewModel @Inject constructor(private val mediaDao: MediaDao) : ViewModel() { + private val _uiState = mutableStateOf( + MediaRestoreListUiState( + mutex = Mutex(), + isLoading = false, + opType = OpType.LIST, + timestamps = listOf(), + selectedIndex = 0, + mediaDao = mediaDao + ) + ) + val uiState: State + get() = _uiState + + suspend fun upsertRestore(item: MediaRestoreEntity) = mediaDao.upsertRestore(item) + private suspend fun queryTimestamps() = mediaDao.queryTimestamps() + suspend fun deleteRestore(item: MediaRestoreEntity) = mediaDao.deleteRestore(item) + + private fun setType(type: OpType) = run { _uiState.value = uiState.value.copy(opType = type) } + + private fun setTimestamps(timestamps: List) { + _uiState.value = uiState.value.copy(timestamps = timestamps) + } + + fun setSelectedIndex(index: Int) { + val uiState by uiState + _uiState.value = uiState.copy(selectedIndex = index) + } + + suspend fun initialize() { + if (uiState.value.opType == OpType.LIST) + withIOContext { + val timestamps = queryTimestamps() + setTimestamps(timestamps) + setSelectedIndex(timestamps.lastIndex) + } + } + + /** + * Clearly tinkering with the itinerary contained within the flow was a bad idea! + * @see https://issuetracker.google.com/issues/291640109#comment9 + */ + suspend fun updateMediaSizeBytes(context: Context, media: MediaRestoreEntity) = withIOContext { + val remoteRootService = RemoteRootService(context) + val sizeBytes = remoteRootService.calculateSize(media.archivePath) + if (media.sizeBytes != sizeBytes) { + upsertRestore(media.copy(sizeBytes = sizeBytes)) + } + remoteRootService.destroyService() + } + + fun onProcessing() { + val uiState by uiState + + viewModelScope.launch { + uiState.mutex.withLock { + if (uiState.opType == OpType.LIST) { + withIOContext { + setType(OpType.PROCESSING) + + val operationLocalService = OperationLocalService(context = DataBackupApplication.application) + operationLocalService.restoreMedium(uiState.timestamp) + operationLocalService.destroyService() + + setType(OpType.LIST) + } + } + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/Completion.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/Completion.kt index 04bba48243..d405b1629e 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/Completion.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/Completion.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -48,7 +49,7 @@ fun PackageBackupCompletion() { val context = LocalContext.current val viewModel = hiltViewModel() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val uiState = viewModel.uiState.value + val uiState by viewModel.uiState val relativeTime = uiState.relativeTime val succeedNum = uiState.succeedNum val failedNum = uiState.failedNum diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/CompletionViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/CompletionViewModel.kt index c1da7f2b8c..ff90fa313b 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/CompletionViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/CompletionViewModel.kt @@ -2,6 +2,7 @@ package com.xayah.databackup.ui.activity.operation.page.packages.backup import android.app.Application import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.xayah.databackup.data.PackageBackupOperationDao @@ -35,7 +36,7 @@ class CompletionViewModel @Inject constructor( get() = _uiState suspend fun initializeUiState() { - val uiState = uiState.value + val uiState by uiState val dao = uiState.packageBackupOperationDao val timestamp = dao.queryLastOperationTime() val startTimestamp = dao.queryFirstOperationStartTime(timestamp) diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/ProcessingViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/ProcessingViewModel.kt index 9e14166b7f..8b40ae417d 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/ProcessingViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/backup/ProcessingViewModel.kt @@ -2,6 +2,7 @@ package com.xayah.databackup.ui.activity.operation.page.packages.backup import android.app.Application import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -15,6 +16,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject enum class ProcessingState { @@ -24,6 +27,7 @@ enum class ProcessingState { } data class ProcessingUiState( + val mutex: Mutex, val timestamp: Long, var effectLaunched: Boolean, var effectFinished: Boolean, @@ -44,6 +48,7 @@ class ProcessingViewModel @Inject constructor( ) : ViewModel() { private val _uiState = mutableStateOf( ProcessingUiState( + mutex = Mutex(), timestamp = DateUtil.getTimestamp(), effectLaunched = false, effectFinished = false, @@ -56,23 +61,28 @@ class ProcessingViewModel @Inject constructor( get() = _uiState fun backupPackages() { - val uiState = uiState.value - if (_uiState.value.effectLaunched.not()) - viewModelScope.launch { - withIOContext { - _uiState.value = uiState.copy(effectLaunched = true) - val operationLocalService = OperationLocalService(context = context) - val preparation = operationLocalService.backupPackagesPreparation() + val uiState by uiState - _uiState.value = uiState.copy(effectState = ProcessingState.Processing) - operationLocalService.backupPackages(timestamp = uiState.timestamp) + viewModelScope.launch { + uiState.mutex.withLock { + if (uiState.effectLaunched.not()) { + withIOContext { + _uiState.value = uiState.copy(effectLaunched = true) - _uiState.value = uiState.copy(effectState = ProcessingState.Waiting) - operationLocalService.backupPackagesAfterwards(preparation) + val operationLocalService = OperationLocalService(context = context) + val preparation = operationLocalService.backupPackagesPreparation() - operationLocalService.destroyService() - _uiState.value = uiState.copy(effectFinished = true) + _uiState.value = uiState.copy(effectState = ProcessingState.Processing) + operationLocalService.backupPackages(timestamp = uiState.timestamp) + + _uiState.value = uiState.copy(effectState = ProcessingState.Waiting) + operationLocalService.backupPackagesAfterwards(preparation) + + operationLocalService.destroyService() + _uiState.value = uiState.copy(effectFinished = true) + } } } + } } } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Completion.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Completion.kt index 5e01542093..5dfa1d5bdd 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Completion.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Completion.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -48,7 +49,7 @@ fun PackageRestoreCompletion() { val context = LocalContext.current val viewModel = hiltViewModel() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val uiState = viewModel.uiState.value + val uiState by viewModel.uiState val relativeTime = uiState.relativeTime val succeedNum = uiState.succeedNum val failedNum = uiState.failedNum diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/CompletionViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/CompletionViewModel.kt index e32fbd601a..b63a93b431 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/CompletionViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/CompletionViewModel.kt @@ -2,6 +2,7 @@ package com.xayah.databackup.ui.activity.operation.page.packages.restore import android.app.Application import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.xayah.databackup.data.PackageRestoreOperationDao @@ -35,7 +36,7 @@ class CompletionViewModel @Inject constructor( get() = _uiState suspend fun initializeUiState() { - val uiState = uiState.value + val uiState by uiState val dao = uiState.packageRestoreOperationDao val timestamp = dao.queryLastOperationTime() val startTimestamp = dao.queryFirstOperationStartTime(timestamp) diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/List.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/List.kt index d1aa899095..a9e263b571 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/List.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/List.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowForward import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -148,7 +149,7 @@ private suspend fun DialogState.openDeleteDialog( ) { openLoading( title = context.getString(R.string.prompt), - icon = ImageVector.vectorResource(context.theme, context.resources, R.drawable.ic_rounded_folder_open), + icon = Icons.Rounded.Delete, onLoading = { viewModel.delete(selectedPackages) val remoteRootService = RemoteRootService(context) @@ -166,6 +167,7 @@ private suspend fun DialogState.openDeleteDialog( selectedPackages.forEach { remoteRootService.deleteRecursively("${PathUtil.getRestorePackagesSavePath()}/${it.packageName}/${it.timestamp}") } + remoteRootService.clearEmptyDirectoriesRecursively(PathUtil.getRestorePackagesSavePath()) } remoteRootService.destroyService() }, @@ -320,7 +322,7 @@ fun PackageRestoreList() { DeleteChip { scope.launch { withIOContext { - dialogSlot.openConfirmDialog(context, context.getString(R.string.confirm_delete)).also { (confirmed, _) -> + dialogSlot.openConfirmDialog(context, context.getString(R.string.confirm_delete_selected_restoring_items)).also { (confirmed, _) -> if (confirmed) { dialogSlot.openDeleteDialog( context = context, diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Processing.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Processing.kt index c8ab8dd6b5..2165f30ff9 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Processing.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/Processing.kt @@ -174,7 +174,7 @@ fun PackageRestoreProcessing() { modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { Column { - ProcessingTopBar(scrollBehavior = scrollBehavior, title = "${stringResource(R.string.backing_up)}($operationCount/$selectedBothCount)") { + ProcessingTopBar(scrollBehavior = scrollBehavior, title = "${stringResource(R.string.restoring)}($operationCount/$selectedBothCount)") { scope.launch { confirmExit(dialogSlot, context) } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/ProcessingViewModel.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/ProcessingViewModel.kt index 9af1b1c799..7543195c4a 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/ProcessingViewModel.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/page/packages/restore/ProcessingViewModel.kt @@ -17,9 +17,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject data class ProcessingUiState( + val mutex: Mutex, val timestamp: Long, var effectLaunched: Boolean, var effectFinished: Boolean, @@ -40,6 +43,7 @@ class ProcessingViewModel @Inject constructor( ) : ViewModel() { private val _uiState = mutableStateOf( ProcessingUiState( + mutex = Mutex(), timestamp = DateUtil.getTimestamp(), effectLaunched = false, effectFinished = false, @@ -53,19 +57,24 @@ class ProcessingViewModel @Inject constructor( fun restorePackages() { val uiState by uiState - if (uiState.effectLaunched.not()) - viewModelScope.launch { - withIOContext { - _uiState.value = uiState.copy(effectLaunched = true) - val operationLocalService = OperationLocalService(context = context) - operationLocalService.restorePackagesPreparation() - _uiState.value = uiState.copy(effectState = ProcessingState.Processing) - operationLocalService.restorePackages(timestamp = uiState.timestamp) + viewModelScope.launch { + uiState.mutex.withLock { + if (uiState.effectLaunched.not()) { + withIOContext { + _uiState.value = uiState.copy(effectLaunched = true) - operationLocalService.destroyService() - _uiState.value = uiState.copy(effectFinished = true) + val operationLocalService = OperationLocalService(context = context) + operationLocalService.restorePackagesPreparation() + + _uiState.value = uiState.copy(effectState = ProcessingState.Processing) + operationLocalService.restorePackages(timestamp = uiState.timestamp) + + operationLocalService.destroyService() + _uiState.value = uiState.copy(effectFinished = true) + } } } + } } } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/NavHost.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/NavHost.kt index 66dbdd956a..e697918bc2 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/NavHost.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/NavHost.kt @@ -9,6 +9,8 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation +import com.xayah.databackup.ui.activity.operation.page.media.backup.PageMediaBackupList +import com.xayah.databackup.ui.activity.operation.page.media.restore.PageMediaRestoreList import com.xayah.databackup.ui.activity.operation.page.packages.backup.PackageBackupCompletion import com.xayah.databackup.ui.activity.operation.page.packages.backup.PackageBackupList import com.xayah.databackup.ui.activity.operation.page.packages.backup.PackageBackupManifest @@ -33,6 +35,14 @@ fun OperationNavHost(startDestination: String) { ) { packageBackupGraph() packageRestoreGraph() + + composable(OperationRoutes.MediaBackup.route) { + PageMediaBackupList() + } + + composable(OperationRoutes.MediaRestore.route) { + PageMediaRestoreList() + } } } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/Routes.kt b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/Routes.kt index 46b6636d9f..e46d0ebaa5 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/Routes.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/activity/operation/router/Routes.kt @@ -12,4 +12,7 @@ sealed class OperationRoutes(val route: String) { object PackageRestoreManifest : OperationRoutes(route = "operation_package_restore_manifest") object PackageRestoreProcessing : OperationRoutes(route = "operation_package_restore_processing") object PackageRestoreCompletion : OperationRoutes(route = "operation_package_restore_completion") + + object MediaBackup : OperationRoutes(route = "operation_media_backup") + object MediaRestore : OperationRoutes(route = "operation_media_restore") } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Animation.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Animation.kt index 0b584317a3..e932dc7a9a 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Animation.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Animation.kt @@ -1,9 +1,19 @@ package com.xayah.databackup.ui.component +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.with import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.ui.unit.Dp @@ -11,6 +21,7 @@ import androidx.compose.ui.unit.dp import com.xayah.databackup.ui.token.AnimationTokens @Composable +@SuppressLint("UnusedTransitionTargetStateParameter") fun emphasizedOffset(targetState: T): State { val transition = updateTransition(targetState, label = AnimationTokens.EmphasizedOffsetLabel) return transition.animateDp(transitionSpec = { @@ -31,4 +42,33 @@ fun emphasizedOffset(targetState: T): State { 0.dp at 300 with FastOutSlowInEasing } }, label = AnimationTokens.EmphasizedOffsetLabel, targetValueByState = { _ -> 0.dp }) -} \ No newline at end of file +} + +@ExperimentalAnimationApi +@Composable +fun AnimatedText( + targetState: String, + content: @Composable() AnimatedVisibilityScope.(targetState: String) -> Unit, +) { + AnimatedContent( + targetState = targetState, + transitionSpec = { + // Compare the incoming number with the previous number. + if (targetState > initialState) { + // If the target number is larger, it slides up and fades in + // while the initial (smaller) number slides up and fades out. + slideInVertically { height -> height } + fadeIn() with slideOutVertically { height -> -height } + fadeOut() + } else { + // If the target number is smaller, it slides down and fades in + // while the initial number slides down and fades out. + slideInVertically { height -> -height } + fadeIn() with slideOutVertically { height -> height } + fadeOut() + }.using( + // Disable clipping since the faded slide-in/out should + // be displayed out of bounds. + SizeTransform(clip = false) + ) + }, + label = AnimationTokens.AnimatedTextLabel, + content = content + ) +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Dialog.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Dialog.kt index da8914eccf..9960bd3072 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Dialog.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Dialog.kt @@ -8,7 +8,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,27 +56,27 @@ class DialogState { confirmText: String? = null, dismissText: String? = null, onLoading: (suspend () -> Unit)? = null, - block: @Composable (MutableState) -> Unit, + block: @Composable (T) -> Unit, ): Pair { return suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { dismiss() } content = { - val uiState = remember { mutableStateOf(initialState) } + val uiState by remember { mutableStateOf(initialState) } AlertDialog( onDismissRequest = { dismiss() - continuation.resume(Pair(false, uiState.value)) + continuation.resume(Pair(false, uiState)) }, confirmButton = { TextButton(text = confirmText ?: stringResource(id = R.string.confirm), onClick = { dismiss() - continuation.resume(Pair(true, uiState.value)) + continuation.resume(Pair(true, uiState)) }) }, dismissButton = { TextButton(text = dismissText ?: stringResource(id = R.string.cancel), onClick = { dismiss() - continuation.resume(Pair(false, uiState.value)) + continuation.resume(Pair(false, uiState)) }) }, title = { Text(text = title) }, diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/ListItem.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/ListItem.kt index 18a530ef33..c8ddafc865 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/ListItem.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/ListItem.kt @@ -1,49 +1,82 @@ package com.xayah.databackup.ui.component +import android.content.Context import android.graphics.BitmapFactory +import android.widget.Toast +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.core.graphics.drawable.toDrawable import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import coil.request.ImageRequest +import com.xayah.databackup.R +import com.xayah.databackup.data.DirectoryEntity +import com.xayah.databackup.data.MediaBackupWithOpEntity +import com.xayah.databackup.data.MediaRestoreEntity +import com.xayah.databackup.data.MediaRestoreWithOpEntity import com.xayah.databackup.data.OperationMask +import com.xayah.databackup.data.OperationState import com.xayah.databackup.data.PackageBackupEntire import com.xayah.databackup.data.PackageRestoreEntire +import com.xayah.databackup.data.StorageType +import com.xayah.databackup.ui.activity.directory.page.DirectoryViewModel +import com.xayah.databackup.ui.activity.operation.page.media.backup.MediaBackupListViewModel +import com.xayah.databackup.ui.activity.operation.page.media.backup.OpType +import com.xayah.databackup.ui.activity.operation.page.media.restore.MediaRestoreListViewModel import com.xayah.databackup.ui.component.material3.Card import com.xayah.databackup.ui.component.material3.outlinedCardBorder import com.xayah.databackup.ui.theme.ColorScheme +import com.xayah.databackup.ui.token.CommonTokens import com.xayah.databackup.ui.token.ListItemTokens +import com.xayah.databackup.util.ConstantUtil import com.xayah.databackup.util.PathUtil +import com.xayah.databackup.util.command.MediumRestoreUtil +import com.xayah.librootservice.service.RemoteRootService +import com.xayah.librootservice.util.ExceptionUtil import com.xayah.librootservice.util.ExceptionUtil.tryOn import com.xayah.librootservice.util.withIOContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.io.File import com.xayah.databackup.ui.activity.operation.page.packages.backup.ListViewModel as BackupListViewModel @@ -298,3 +331,443 @@ fun ListItemManifest(icon: ImageVector, title: String, content: String, onButton } } } + +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun ListItemDirectory( + modifier: Modifier = Modifier, + entity: DirectoryEntity, + onCardClick: () -> Unit, + chipGroup: @Composable RowScope.() -> Unit, +) { + val context = LocalContext.current + val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() + val dialogSlot = LocalSlotScope.current!!.dialogSlot + val haptic = LocalHapticFeedback.current + val selected = entity.selected + val enabled = entity.enabled + val path = entity.path + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + enabled = enabled, + onClick = onCardClick, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + expanded = true + }, + border = if (selected) outlinedCardBorder(lineColor = ColorScheme.primary()) else null, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(ListItemTokens.PaddingMedium) + ) { + Column { + Row { + HeadlineMediumBoldText(text = entity.title) + Spacer(modifier = Modifier.weight(1f)) + if (selected) Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.align(Alignment.Top), + tint = ColorScheme.primary(), + ) + } + BodySmallBoldText(text = path) + if (entity.error.isNotEmpty()) BodySmallBoldText(text = entity.error, color = ColorScheme.error(), enabled = enabled) + Divider(modifier = Modifier.paddingVertical(ListItemTokens.PaddingSmall)) + Row( + modifier = Modifier.paddingBottom(ListItemTokens.PaddingSmall), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ListItemTokens.PaddingMedium) + ) { + LinearProgressIndicator( + modifier = Modifier + .clip(CircleShape) + .weight(1f), + color = if (enabled) ColorScheme.primary() else ColorScheme.primary().copy(alpha = CommonTokens.DisabledAlpha), + trackColor = if (enabled) ColorScheme.inverseOnSurface() else ColorScheme.inverseOnSurface().copy(alpha = CommonTokens.DisabledAlpha), + progress = entity.availableBytes.toFloat() / entity.totalBytes + ) + Box(modifier = Modifier.wrapContentSize(Alignment.Center)) { + val actions = remember(entity) { + listOf( + ActionMenuItem( + title = context.getString(R.string.delete), + icon = Icons.Rounded.Delete, + enabled = entity.storageType == StorageType.CUSTOM, + onClick = { + scope.launch { + withIOContext { + expanded = false + dialogSlot.openConfirmDialog(context, context.getString(R.string.confirm_delete)).also { (confirmed, _) -> + if (confirmed) { + if (entity.selected) viewModel.reset(context = context) + viewModel.delete(entity) + } + } + } + } + } + ) + ) + } + + LabelSmallText(text = "${entity.availableBytesDisplay} / ${entity.totalBytesDisplay}") + + ModalActionDropdownMenu(expanded = expanded, actionList = actions, onDismissRequest = { expanded = false }) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ListItemTokens.PaddingSmall), + content = { + chipGroup() + } + ) + } + } + } +} + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun ListItemMedia( + modifier: Modifier = Modifier, + name: String, + path: String, + state: Boolean, + mediaOpLog: String, + isProcessing: Boolean, + selectedInList: Boolean, + mediaOpProcessing: Boolean, + mediaOpDone: Boolean, + onCardClick: () -> Unit, + onCardLongClick: () -> Unit, + menuPlaceholder: @Composable (BoxScope.() -> Unit), + chipGroup: @Composable (RowScope.() -> Unit), +) { + val haptic = LocalHapticFeedback.current + + Card( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + onClick = { + if (isProcessing.not()) { + onCardClick() + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (isProcessing.not()) { + onCardLongClick() + } + }, + border = if (selectedInList) outlinedCardBorder(lineColor = ColorScheme.primary()) else null, + ) { + Column(verticalArrangement = Arrangement.spacedBy(ListItemTokens.PaddingMedium)) { + Row( + modifier = Modifier + .paddingTop(ListItemTokens.PaddingMedium) + .paddingHorizontal(ListItemTokens.PaddingMedium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ListItemTokens.PaddingSmall) + ) { + Column(modifier = Modifier.weight(1f)) { + TitleMediumBoldText(text = name) + LabelSmallText(text = path) + } + if (selectedInList) Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.align(Alignment.Top), + tint = ColorScheme.primary(), + ) + if (mediaOpProcessing) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterVertically) + .size(ListItemTokens.OpIndicatorSize), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .paddingHorizontal(ListItemTokens.PaddingMedium), + ) { + menuPlaceholder() + Row( + modifier = Modifier + .fillMaxWidth(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(ListItemTokens.PaddingSmall), + content = { + chipGroup() + if (mediaOpDone) { + AnimatedSerial( + serial = if (state) stringResource(id = R.string.succeed) else stringResource(id = R.string.failed) + ) + } + } + ) + } + if (mediaOpProcessing || mediaOpDone) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = ColorScheme.inverseOnSurface()) + .paddingHorizontal(ListItemTokens.PaddingMedium), + ) { + Box( + modifier + .weight(1f) + .paddingVertical(ListItemTokens.PaddingSmall), + contentAlignment = Alignment.CenterStart + ) { + LabelSmallText(text = mediaOpLog) + } + } + } else { + Spacer(modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun ListItemMediaBackup( + modifier: Modifier = Modifier, + entity: MediaBackupWithOpEntity, +) { + val context = LocalContext.current + val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() + val dialogSlot = LocalSlotScope.current!!.dialogSlot + val uiState by viewModel.uiState + val media = entity.media + val opList = entity.opList + val isProcessing = remember(uiState) { uiState.opType == OpType.PROCESSING } + var selectedInList by remember(entity, isProcessing) { mutableStateOf(media.selected && isProcessing.not()) } + val mediaOpIndex by remember(entity, uiState) { mutableIntStateOf(entity.opList.indexOfLast { it.timestamp == uiState.timestamp }) } + val mediaOpProcessing by remember(entity, mediaOpIndex) { mutableStateOf(mediaOpIndex != -1 && opList[mediaOpIndex].opState == OperationState.Processing) } + val mediaOpDone by remember( + entity, + mediaOpIndex + ) { mutableStateOf(mediaOpIndex != -1 && (opList[mediaOpIndex].opState != OperationState.IDLE && opList[mediaOpIndex].opState != OperationState.Processing)) } + val mediaOpLog by remember( + entity, + mediaOpProcessing + ) { mutableStateOf(if (mediaOpProcessing || mediaOpDone) opList[mediaOpIndex].opLog else context.getString(R.string.idle)) } + var expanded by remember { mutableStateOf(false) } + + ListItemMedia( + modifier = modifier, + name = media.name, + path = media.path, + state = if (mediaOpDone) opList[mediaOpIndex].state else false, + mediaOpLog = mediaOpLog, + isProcessing = isProcessing, + selectedInList = selectedInList, + mediaOpProcessing = mediaOpProcessing, + mediaOpDone = mediaOpDone, + onCardClick = { + scope.launch { + withIOContext { + media.selected = media.selected.not() + viewModel.upsertBackup(media) + selectedInList = media.selected + } + } + }, + onCardLongClick = { + expanded = true + }, + menuPlaceholder = { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .wrapContentSize(Alignment.Center) + ) { + val actions = remember(entity) { + listOf( + ActionMenuItem( + title = context.getString(R.string.delete), + icon = Icons.Rounded.Delete, + enabled = ConstantUtil.DefaultMediaList.indexOfFirst { it.second == entity.media.path } == -1, + onClick = { + scope.launch { + withIOContext { + expanded = false + dialogSlot.openConfirmDialog(context, context.getString(R.string.confirm_delete)).also { (confirmed, _) -> + if (confirmed) { + viewModel.deleteBackup(entity.media) + } + } + } + } + } + ) + ) + } + + Spacer(modifier = Modifier.align(Alignment.BottomEnd)) + + ModalActionDropdownMenu(expanded = expanded, actionList = actions, onDismissRequest = { expanded = false }) + } + }, + chipGroup = { + LaunchedEffect(null) { + viewModel.updateMediaSizeBytes(context, entity.media) + } + AnimatedSerial(serial = entity.media.sizeDisplay) + } + ) +} + +@ExperimentalMaterial3Api +private suspend fun DialogState.openMediaRestoreDeleteDialog( + context: Context, + scope: CoroutineScope, + viewModel: MediaRestoreListViewModel, + entity: MediaRestoreEntity, +) { + openLoading( + title = context.getString(R.string.prompt), + icon = Icons.Rounded.Delete, + onLoading = { + viewModel.deleteRestore(entity) + val remoteRootService = RemoteRootService(context) + ExceptionUtil.tryService(onFailed = { msg -> + scope.launch { + withIOContext { + Toast.makeText( + context, + "${context.getString(R.string.fetch_failed)}: $msg\n${context.getString(R.string.remote_service_err_info)}", + Toast.LENGTH_SHORT + ).show() + } + } + }) { + val path = MediumRestoreUtil.getMediaItemSavePath(entity.name, entity.timestamp) + remoteRootService.deleteRecursively(path) + remoteRootService.clearEmptyDirectoriesRecursively(PathUtil.getRestoreMediumSavePath()) + } + remoteRootService.destroyService() + viewModel.initialize() + }, + ) +} + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalMaterial3Api +@Composable +fun ListItemMediaRestore( + modifier: Modifier = Modifier, + entity: MediaRestoreWithOpEntity, +) { + val context = LocalContext.current + val viewModel = hiltViewModel() + val dialogSlot = LocalSlotScope.current!!.dialogSlot + val scope = rememberCoroutineScope() + val uiState by viewModel.uiState + val media = entity.media + val opList = entity.opList + val isProcessing = remember(uiState) { uiState.opType == OpType.PROCESSING } + var selectedInList by remember(entity, isProcessing) { mutableStateOf(media.selected && isProcessing.not()) } + val mediaOpIndex by remember(entity, uiState) { mutableIntStateOf(entity.opList.indexOfLast { it.timestamp == uiState.timestamp }) } + val mediaOpProcessing by remember(entity, mediaOpIndex) { mutableStateOf(mediaOpIndex != -1 && opList[mediaOpIndex].opState == OperationState.Processing) } + val mediaOpDone by remember( + entity, + mediaOpIndex + ) { mutableStateOf(mediaOpIndex != -1 && (opList[mediaOpIndex].opState != OperationState.IDLE && opList[mediaOpIndex].opState != OperationState.Processing)) } + val mediaOpLog by remember( + entity, + mediaOpProcessing + ) { mutableStateOf(if (mediaOpProcessing || mediaOpDone) opList[mediaOpIndex].opLog else context.getString(R.string.idle)) } + var expanded by remember { mutableStateOf(false) } + + ListItemMedia( + modifier = modifier, + name = media.name, + path = media.path, + state = if (mediaOpDone) opList[mediaOpIndex].state else false, + mediaOpLog = mediaOpLog, + isProcessing = isProcessing, + selectedInList = selectedInList, + mediaOpProcessing = mediaOpProcessing, + mediaOpDone = mediaOpDone, + onCardClick = { + scope.launch { + withIOContext { + media.selected = media.selected.not() + viewModel.upsertRestore(media) + selectedInList = media.selected + } + } + }, + onCardLongClick = { + expanded = true + }, + menuPlaceholder = { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .wrapContentSize(Alignment.Center) + ) { + val actions = remember(entity) { + listOf( + ActionMenuItem( + title = context.getString(R.string.delete), + icon = Icons.Rounded.Delete, + enabled = true, + onClick = { + scope.launch { + withIOContext { + expanded = false + dialogSlot.openConfirmDialog(context, context.getString(R.string.confirm_delete_selected_restoring_items)) + .also { (confirmed, _) -> + if (confirmed) { + dialogSlot.openMediaRestoreDeleteDialog( + context = context, + scope = scope, + viewModel = viewModel, + entity = entity.media + ) + } + } + } + } + } + ) + ) + } + + Spacer(modifier = Modifier.align(Alignment.BottomEnd)) + + ModalActionDropdownMenu(expanded = expanded, actionList = actions, onDismissRequest = { expanded = false }) + } + }, + chipGroup = { + LaunchedEffect(null) { + viewModel.updateMediaSizeBytes(context, entity.media) + } + AnimatedSerial(serial = entity.media.sizeDisplay) + } + ) +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Loader.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Loader.kt index c9a3908508..51abc90ea8 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Loader.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Loader.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,12 +32,26 @@ fun Loader(modifier: Modifier, onLoading: suspend () -> Unit = {}, content: @Com } } +@Composable +fun Loader(modifier: Modifier, isLoading: Boolean, content: @Composable () -> Unit) { + Crossfade( + targetState = isLoading, + label = AnimationTokens.CrossFadeLabel + ) { state -> + when (state) { + true -> Box(modifier = modifier, contentAlignment = Alignment.Center) { CircularProgressIndicator() } + + false -> content() + } + } +} + @Composable fun Loader( modifier: Modifier, onLoading: suspend () -> Unit = {}, - uiState: MutableState, - content: @Composable (MutableState) -> Unit + uiState: T, + content: @Composable (T) -> Unit, ) { var isLoading by remember { mutableStateOf(true) } LaunchedEffect(null) { diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Menu.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Menu.kt index 2365bd67a5..58fe0e436b 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Menu.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Menu.kt @@ -333,6 +333,55 @@ fun ModalStringListDropdownMenu( } } +data class ActionMenuItem( + val title: String, + val icon: ImageVector, + val enabled: Boolean, + val onClick: () -> Unit, +) + +@Composable +fun ModalActionDropdownMenu( + expanded: Boolean, + actionList: List, + maxDisplay: Int? = null, + onDismissRequest: () -> Unit, +) { + ModalDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + var itemHeightPx by remember { mutableIntStateOf(0) } + var modifier: Modifier = Modifier + if (maxDisplay != null) { + val scrollState = rememberScrollState() + with(LocalDensity.current) { + /** + * If [maxDisplay] is non-null, limit the max height. + */ + modifier = Modifier + .heightIn(max = ((itemHeightPx * maxDisplay).toDp())) + .verticalScroll(scrollState) + } + } + Column(modifier = modifier) { + actionList.forEach { item -> + DropdownMenuItem( + modifier = Modifier + .background(ColorScheme.onPrimary()) + .onSizeChanged { itemHeightPx = it.height }, + text = { + Text( + modifier = Modifier.paddingHorizontal(MenuTokens.ModalDropdownMenuPadding), + text = item.title, + ) + }, + enabled = item.enabled, + onClick = item.onClick, + leadingIcon = { Icon(imageVector = item.icon, contentDescription = null) }, + ) + } + } + } +} + @Composable fun ChipDropdownMenu( label: String? = null, diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Scaffold.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Scaffold.kt index 21e6b1f6c1..69f7f96dcc 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Scaffold.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Scaffold.kt @@ -1,29 +1,46 @@ package com.xayah.databackup.ui.component +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FabPosition import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController import com.xayah.databackup.R import com.xayah.databackup.ui.activity.crash.CrashViewModel import com.xayah.databackup.ui.activity.guide.page.GuideViewModel import com.xayah.databackup.ui.activity.main.page.MainViewModel +import com.xayah.databackup.ui.activity.operation.page.media.backup.OpType import com.xayah.databackup.ui.theme.ColorScheme import com.xayah.databackup.ui.token.CommonTokens @@ -34,7 +51,7 @@ fun GuideScaffold( viewModel: GuideViewModel, content: @Composable () -> Unit ) { - val uiState = viewModel.uiState.value + val uiState by viewModel.uiState Scaffold( floatingActionButtonPosition = FabPosition.Center, floatingActionButton = { @@ -56,9 +73,9 @@ fun GuideScaffold( @ExperimentalMaterial3Api @Composable fun MainScaffold(viewModel: MainViewModel, content: @Composable () -> Unit) { - val uiState = viewModel.uiState.value + val uiState by viewModel.uiState Scaffold( - modifier = if (uiState.scrollBehavior != null) Modifier.nestedScroll(uiState.scrollBehavior.nestedScrollConnection) else Modifier, + modifier = if (uiState.scrollBehavior != null) Modifier.nestedScroll(uiState.scrollBehavior!!.nestedScrollConnection) else Modifier, topBar = uiState.topBar, bottomBar = uiState.bottomBar ?: {}, floatingActionButton = uiState.floatingActionButton, @@ -106,3 +123,86 @@ fun CrashScaffold(viewModel: CrashViewModel) { } } } + +@ExperimentalMaterial3Api +@Composable +fun DirectoryScaffold(title: String, onFabClick: () -> Unit, content: @Composable () -> Unit) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + DirectoryTopBar(scrollBehavior = scrollBehavior, title = title) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onFabClick, + expanded = true, + icon = { Icon(imageVector = Icons.Rounded.Add, contentDescription = null) }, + text = { Text(text = stringResource(id = R.string.add)) }, + ) + }, + floatingActionButtonPosition = FabPosition.Center + ) { innerPadding -> + Column { + TopSpacer(innerPadding = innerPadding) + + Box(modifier = Modifier.weight(1f)) { + content() + } + } + } +} + +@ExperimentalAnimationApi +@ExperimentalMaterial3Api +@Composable +fun MediaListScaffold( + title: String, + selectedCount: Int, + opType: OpType, + onFabClick: () -> Unit, + onAddClick: (() -> Unit)?, + content: @Composable () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val isProcessing = remember(opType) { opType == OpType.PROCESSING } + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediaListTopBar(scrollBehavior = scrollBehavior, title = title, actionsVisible = onAddClick != null && isProcessing.not(), onAddClick = onAddClick) + }, + floatingActionButton = { + AnimatedVisibility(visible = isProcessing.not(), enter = scaleIn(), exit = scaleOut()) { + var emphasizedState by remember { mutableStateOf(false) } + val emphasizedOffset by emphasizedOffset(targetState = emphasizedState) + val selected = remember(selectedCount) { selectedCount != 0 } + ExtendedFloatingActionButton( + modifier = Modifier + .padding(CommonTokens.PaddingMedium) + .offset(x = emphasizedOffset), + onClick = { + if (selected.not()) emphasizedState = !emphasizedState + else onFabClick() + }, + expanded = selected, + icon = { + Icon( + imageVector = if (selected) Icons.Rounded.ArrowForward else Icons.Rounded.Close, + contentDescription = null + ) + }, + text = { Text(text = "${stringArrayResource(id = R.array.filter_type_items)[1]}: $selectedCount") }, + ) + } + }, + floatingActionButtonPosition = FabPosition.Center + ) { innerPadding -> + Column { + TopSpacer(innerPadding = innerPadding) + + Box(modifier = Modifier.weight(1f)) { + content() + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Serial.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Serial.kt index 8e8ca9a5e7..5b85a2201c 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Serial.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Serial.kt @@ -1,5 +1,6 @@ package com.xayah.databackup.ui.component +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height @@ -32,11 +33,11 @@ fun Serial(modifier: Modifier = Modifier, serial: Char) { } @Composable -fun Serial(modifier: Modifier = Modifier, serial: String) { +fun Serial(modifier: Modifier = Modifier, serial: String, enabled: Boolean = true) { Surface( shape = CircleShape, modifier = modifier.height(SerialTokens.CircleSize), - color = ColorScheme.onSurfaceVariant() + color = if (enabled) ColorScheme.onSurfaceVariant() else ColorScheme.onSurfaceVariant().copy(alpha = SerialTokens.DisabledAlpha) ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -45,8 +46,31 @@ fun Serial(modifier: Modifier = Modifier, serial: String) { TitleSmallBoldText( modifier = Modifier.paddingHorizontal(SerialTokens.PaddingHorizontal), text = serial, - color = ColorScheme.surface() + color = if (enabled) ColorScheme.surface() else ColorScheme.surface().copy(alpha = SerialTokens.DisabledAlpha) ) } } } + +@ExperimentalAnimationApi +@Composable +fun AnimatedSerial(modifier: Modifier = Modifier, serial: String, enabled: Boolean = true) { + Surface( + shape = CircleShape, + modifier = modifier.height(SerialTokens.CircleSize), + color = if (enabled) ColorScheme.onSurfaceVariant() else ColorScheme.onSurfaceVariant().copy(alpha = SerialTokens.DisabledAlpha) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AnimatedText(targetState = serial) { + TitleSmallBoldText( + modifier = Modifier.paddingHorizontal(SerialTokens.PaddingHorizontal), + text = serial, + color = if (enabled) ColorScheme.surface() else ColorScheme.surface().copy(alpha = SerialTokens.DisabledAlpha) + ) + } + } + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/Text.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/Text.kt index 821c4a8a0a..52f96c9aa1 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/Text.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/Text.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import com.xayah.databackup.ui.theme.JetbrainsMonoFamily +import com.xayah.databackup.ui.token.CommonTokens @Composable fun TopBarTitle(modifier: Modifier = Modifier, textAlign: TextAlign? = null, text: String) { @@ -130,12 +131,13 @@ fun BodySmallText(modifier: Modifier = Modifier, text: String) { } @Composable -fun BodySmallBoldText(modifier: Modifier = Modifier, text: String) { +fun BodySmallBoldText(modifier: Modifier = Modifier, text: String, color: Color = Color.Unspecified, enabled: Boolean = true) { Text( modifier = modifier, text = text, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold, + color = if (enabled) color else color.copy(alpha = CommonTokens.DisabledAlpha), ) } @@ -200,8 +202,8 @@ fun JetbrainsMonoText(modifier: Modifier = Modifier, text: String, style: TextSt } @Composable -fun JetbrainsMonoLabelMediumText(modifier: Modifier = Modifier, text: String) { - JetbrainsMonoText(modifier = modifier, text = text, style = MaterialTheme.typography.labelMedium) +fun JetbrainsMonoLabelSmallText(modifier: Modifier = Modifier, text: String) { + JetbrainsMonoText(modifier = modifier, text = text, style = MaterialTheme.typography.labelSmall) } @Composable diff --git a/source/app/src/main/java/com/xayah/databackup/ui/component/TopBar.kt b/source/app/src/main/java/com/xayah/databackup/ui/component/TopBar.kt index 60c60dbc33..e707244538 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/component/TopBar.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/component/TopBar.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -121,7 +122,7 @@ fun TreeTopBar(scrollBehavior: TopAppBarScrollBehavior?, viewModel: TreeViewMode actions = { val scope = rememberCoroutineScope() var expanded by remember { mutableStateOf(false) } - val uiState = viewModel.uiState.value + val uiState by viewModel.uiState val typeList = uiState.typeList val typeStringList = uiState.typeList.map { it.ofString(context) } val selectedIndex = uiState.selectedIndex @@ -156,7 +157,7 @@ fun TreeTopBar(scrollBehavior: TopAppBarScrollBehavior?, viewModel: TreeViewMode fun LogTopBar(scrollBehavior: TopAppBarScrollBehavior?, viewModel: LogViewModel) { val context = LocalContext.current val scope = rememberCoroutineScope() - val uiState = viewModel.uiState.value + val uiState by viewModel.uiState val dateList = uiState.startTimestamps.map { timestamp -> DateUtil.formatTimestamp(timestamp) } val selectedIndex = uiState.selectedIndex val navController = LocalSlotScope.current!!.navController @@ -396,3 +397,37 @@ fun CompletionTopBar(scrollBehavior: TopAppBarScrollBehavior, title: String) { }, ) } + +@ExperimentalMaterial3Api +@Composable +fun DirectoryTopBar(scrollBehavior: TopAppBarScrollBehavior?, title: String) { + val context = LocalContext.current + CenterAlignedTopAppBar( + title = { TopBarTitle(text = title) }, + scrollBehavior = scrollBehavior, + navigationIcon = { + ArrowBackButton { + (context as ComponentActivity).finish() + } + }, + ) +} + +@ExperimentalMaterial3Api +@Composable +fun MediaListTopBar(scrollBehavior: TopAppBarScrollBehavior?, title: String, actionsVisible: Boolean, onAddClick: (() -> Unit)?) { + val context = LocalContext.current + CenterAlignedTopAppBar( + title = { TopBarTitle(text = title) }, + scrollBehavior = scrollBehavior, + navigationIcon = { + ArrowBackButton { + (context as ComponentActivity).finish() + } + }, + actions = { + if (actionsVisible) + IconButton(onClick = { onAddClick?.invoke() }) { Icon(imageVector = Icons.Rounded.Add, contentDescription = null) } + } + ) +} diff --git a/source/app/src/main/java/com/xayah/databackup/ui/token/AnimationTokens.kt b/source/app/src/main/java/com/xayah/databackup/ui/token/AnimationTokens.kt index dcb6d45b3c..e9839d3708 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/token/AnimationTokens.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/token/AnimationTokens.kt @@ -5,4 +5,5 @@ object AnimationTokens { const val EmphasizedOffsetLabel = "EmphasizedOffset" const val AnimateFloatAsStateLabel = "AnimateFloatAsState" const val TweenDuration = 1000 + const val AnimatedTextLabel = "AnimatedText" } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/token/CommonTokens.kt b/source/app/src/main/java/com/xayah/databackup/ui/token/CommonTokens.kt index 12d25ab7c6..d211f8e6b3 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/token/CommonTokens.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/token/CommonTokens.kt @@ -13,4 +13,5 @@ object CommonTokens { val PaddingMedium = 16.dp val PaddingLarge = 24.dp val EmojiLargeSize = 100.sp + const val DisabledAlpha = 0.38f } diff --git a/source/app/src/main/java/com/xayah/databackup/ui/token/ListItemTokens.kt b/source/app/src/main/java/com/xayah/databackup/ui/token/ListItemTokens.kt index 52107acfdd..ab70e25397 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/token/ListItemTokens.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/token/ListItemTokens.kt @@ -8,4 +8,5 @@ object ListItemTokens { val PaddingMedium = 16.dp val ManifestIconButtonSize = 48.dp val ManifestIconSize = 28.dp + val OpIndicatorSize = 24.dp } \ No newline at end of file diff --git a/source/app/src/main/java/com/xayah/databackup/ui/token/SerialTokens.kt b/source/app/src/main/java/com/xayah/databackup/ui/token/SerialTokens.kt index 84876653c8..9aa3c0239e 100644 --- a/source/app/src/main/java/com/xayah/databackup/ui/token/SerialTokens.kt +++ b/source/app/src/main/java/com/xayah/databackup/ui/token/SerialTokens.kt @@ -5,4 +5,5 @@ import androidx.compose.ui.unit.dp object SerialTokens { val CircleSize = 24.dp val PaddingHorizontal = 12.dp + const val DisabledAlpha = 0.38f } \ No newline at end of file diff --git a/source/app/src/main/java/com/xayah/databackup/util/ConstantUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/ConstantUtil.kt index d532ce05df..e6e257709b 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/ConstantUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/ConstantUtil.kt @@ -3,14 +3,12 @@ package com.xayah.databackup.util import com.xayah.databackup.ui.activity.main.router.MainRoutes object ConstantUtil { - const val DefaultBackupParent = "/storage/emulated/0" - const val DefaultBackupChild = "DataBackup" - const val DefaultBackupSavePath = "${DefaultBackupParent}/${DefaultBackupChild}" + const val DefaultPathParent = "/storage/emulated/0" + const val DefaultPathChild = "DataBackup" + const val DefaultPath = "${DefaultPathParent}/${DefaultPathChild}" + const val DefaultBackupUserId = 0 - const val DefaultRestoreParent = "/storage/emulated/0" - const val DefaultRestoreChild = "DataBackup" - const val DefaultRestoreSavePath = "${DefaultRestoreParent}/${DefaultRestoreChild}" const val DefaultRestoreUserId = 0 val SupportedExternalStorageFormat = listOf( @@ -30,4 +28,11 @@ object ConstantUtil { ) const val ClipDataLabel = "DataBackupClipData" + + val DefaultMediaList = listOf( + Pair("Pictures","/storage/emulated/0/Pictures"), + Pair("Music","/storage/emulated/0/Music"), + Pair("DCIM","/storage/emulated/0/DCIM"), + Pair("Download","/storage/emulated/0/Download"), + ) } diff --git a/source/app/src/main/java/com/xayah/databackup/util/IntentUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/IntentUtil.kt index ddd32bef48..1150fed30f 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/IntentUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/IntentUtil.kt @@ -2,6 +2,8 @@ package com.xayah.databackup.util import android.content.Context import android.content.Intent +import com.xayah.databackup.ui.activity.directory.DirectoryActivity +import com.xayah.databackup.ui.activity.directory.router.DirectoryRoutes import com.xayah.databackup.ui.activity.operation.OperationActivity import com.xayah.databackup.ui.activity.operation.router.OperationRoutes @@ -13,4 +15,10 @@ object IntentUtil { intent.putExtra(ExtraRoute, route.route) }) } + + fun toDirectoryActivity(context: Context, route: DirectoryRoutes) { + context.startActivity(Intent(context, DirectoryActivity::class.java).also { intent -> + intent.putExtra(ExtraRoute, route.route) + }) + } } \ No newline at end of file diff --git a/source/app/src/main/java/com/xayah/databackup/util/PathUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/PathUtil.kt index c27f453aef..f24425aa09 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/PathUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/PathUtil.kt @@ -30,14 +30,15 @@ object PathUtil { fun getTmpApkPath(context: Context, packageName: String): String = "${context.filesPath()}/tmp/apks/$packageName" - fun getTmpConfigPath(context: Context, packageName: String, timestamp: Long): String = "${context.filesPath()}/tmp/config/$packageName/$timestamp" - fun getTmpConfigFilePath(context: Context, packageName: String, timestamp: Long): String = - "${getTmpConfigPath(context, packageName, timestamp)}/PackageRestoreEntire" + fun getTmpConfigPath(context: Context, name: String, timestamp: Long): String = "${context.filesPath()}/tmp/config/$name/$timestamp" + fun getTmpConfigFilePath(context: Context, name: String, timestamp: Long): String = + "${getTmpConfigPath(context, name, timestamp)}/PackageRestoreEntire" // Paths for backup save dir. fun getBackupSavePath(): String = DataBackupApplication.application.readBackupSavePath() private fun getBackupArchivesSavePath(): String = "${getBackupSavePath()}/archives" fun getBackupPackagesSavePath(): String = "${getBackupArchivesSavePath()}/packages" + fun getBackupMediumSavePath(): String = "${getBackupArchivesSavePath()}/medium" private fun getTreeSavePath(): String = "${getBackupSavePath()}/tree" fun getTreeSavePath(timestamp: Long): String = "${getTreeSavePath()}/tree_${timestamp}" fun getIconSavePath(): String = "${getBackupSavePath()}/icon" @@ -47,8 +48,9 @@ object PathUtil { // Paths for restore save dir. private fun getRestoreSavePath(): String = DataBackupApplication.application.readRestoreSavePath() - private fun getRestoreArchivesSavePath(): String = "${getRestoreSavePath()}/archives" + fun getRestoreArchivesSavePath(): String = "${getRestoreSavePath()}/archives" fun getRestorePackagesSavePath(): String = "${getRestoreArchivesSavePath()}/packages" + fun getRestoreMediumSavePath(): String = "${getRestoreArchivesSavePath()}/medium" fun getRestoreIconSavePath(): String = "${getRestoreSavePath()}/icon" // Paths for processing. diff --git a/source/app/src/main/java/com/xayah/databackup/util/PreferenceUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/PreferenceUtil.kt index cf547f71cb..6e63bccce9 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/PreferenceUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/PreferenceUtil.kt @@ -45,7 +45,8 @@ enum class DataType(val type: String) { PACKAGE_OBB("obb"), PACKAGE_MEDIA("media"), // /data/media/$user_id/Android/media PACKAGE_CONFIG("config"), // Json file for reloading - MEDIA_MEDIA("media"); + MEDIA_MEDIA("media"), + MEDIA_CONFIG("config"); fun origin(userId: Int): String = when (this) { PACKAGE_USER -> PathUtil.getPackageUserPath(userId) @@ -288,42 +289,7 @@ fun Context.saveBackupSavePath(path: String) { * @see [saveBackupSavePath] */ fun Context.readBackupSavePath(): String { - return readPreferencesString("backup_save_path", ConstantUtil.DefaultBackupSavePath) - ?: ConstantUtil.DefaultBackupSavePath -} - -/** - * The child of internal path. - * e.g. "DataBackup" in "/storage/emulated/0/DataBackup". - */ -fun Context.saveInternalBackupSaveChild(child: String) { - savePreferences("backup_save_child_internal", child.trim()) -} - -/** - * @see [saveInternalBackupSaveChild] - */ -fun Context.readInternalBackupSaveChild(): String { - return readPreferencesString("backup_save_child_internal", ConstantUtil.DefaultBackupChild) - ?: ConstantUtil.DefaultBackupChild -} - -/** - * The child of external path. - * Though there could be more than one external storage devices, - * we save only one child of them. - * e.g. "DataBackup" in "/mnt/media_rw/E7F9-FA61/DataBackup". - */ -fun Context.saveExternalBackupSaveChild(child: String) { - savePreferences("backup_save_child_external", child.trim()) -} - -/** - * @see [saveExternalBackupSaveChild] - */ -fun Context.readExternalBackupSaveChild(): String { - return readPreferencesString("backup_save_child_external", ConstantUtil.DefaultBackupChild) - ?: ConstantUtil.DefaultBackupChild + return readPreferencesString("backup_save_path", ConstantUtil.DefaultPath) ?: ConstantUtil.DefaultPath } /** @@ -353,42 +319,7 @@ fun Context.saveRestoreSavePath(path: String) { * @see [saveRestoreSavePath] */ fun Context.readRestoreSavePath(): String { - return readPreferencesString("restore_save_path", ConstantUtil.DefaultRestoreSavePath) - ?: ConstantUtil.DefaultRestoreSavePath -} - -/** - * The child of internal path. - * e.g. "DataBackup" in "/storage/emulated/0/DataBackup". - */ -fun Context.saveInternalRestoreSaveChild(child: String) { - savePreferences("restore_save_child_internal", child.trim()) -} - -/** - * @see [saveInternalRestoreSaveChild] - */ -fun Context.readInternalRestoreSaveChild(): String { - return readPreferencesString("restore_save_child_internal", ConstantUtil.DefaultRestoreChild) - ?: ConstantUtil.DefaultRestoreChild -} - -/** - * The child of external path. - * Though there could be more than one external storage devices, - * we save only one child of them. - * e.g. "DataBackup" in "/mnt/media_rw/E7F9-FA61/DataBackup". - */ -fun Context.saveExternalRestoreSaveChild(child: String) { - savePreferences("restore_save_child_external", child.trim()) -} - -/** - * @see [saveExternalRestoreSaveChild] - */ -fun Context.readExternalRestoreSaveChild(): String { - return readPreferencesString("restore_save_child_external", ConstantUtil.DefaultRestoreChild) - ?: ConstantUtil.DefaultRestoreChild + return readPreferencesString("restore_save_path", ConstantUtil.DefaultPath) ?: ConstantUtil.DefaultPath } fun Context.saveIconSaveTime(timestamp: Long) { diff --git a/source/app/src/main/java/com/xayah/databackup/util/command/CompressionUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/command/CompressionUtil.kt index d067c8476d..79132e6bb5 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/command/CompressionUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/command/CompressionUtil.kt @@ -4,6 +4,7 @@ import android.content.Context import com.xayah.databackup.util.CompressionType import com.xayah.databackup.util.DataType import com.xayah.databackup.util.LogUtil +import com.xayah.databackup.util.PathUtil import com.xayah.databackup.util.SymbolUtil.QUOTE import com.xayah.databackup.util.command.CommonUtil.executeWithLog import com.xayah.databackup.util.command.CommonUtil.outString @@ -13,7 +14,55 @@ import com.xayah.librootservice.service.RemoteRootService fun List.toSpaceString() = joinToString(separator = " ") object CompressionUtil { - suspend fun compress( + private suspend fun compress( + logUtil: LogUtil, + logId: Long, + compatibleMode: Boolean, + compressionType: CompressionType, + originPathPara: String, + archivePath: String, + excludeParaList: List, + ): Pair { + var isSuccess = true + val outList = mutableListOf() + + val cmd = if (compatibleMode) + "- -C $originPathPara ${if (compressionType == CompressionType.TAR) "" else "| ${compressionType.compressPara}"} > $archivePath" + else + "$archivePath -C $originPathPara ${if (compressionType == CompressionType.TAR) "" else "-I $QUOTE${compressionType.compressPara}$QUOTE"}" + + // Compress data dir. + logUtil.executeWithLog(logId, "tar --totals ${excludeParaList.toSpaceString()} -cpf $cmd").also { result -> + if (result.isSuccess.not()) isSuccess = false + outList.add(result.outString()) + } + + return Pair(isSuccess, outList.toLineString().trim()) + } + + private suspend fun decompress( + logUtil: LogUtil, + logId: Long, + compressionType: CompressionType, + originPath: String, + archivePath: String, + cleanRestoringPara: String, + excludeParaList: List, + ): Pair { + var isSuccess = true + val outList = mutableListOf() + + val cmd = "$archivePath -C $originPath ${compressionType.decompressPara}" + // Decompress the archive. + logUtil.executeWithLog(logId, "tar --totals ${excludeParaList.toSpaceString()} $cleanRestoringPara -xmpf $cmd").also { result -> + if (result.isSuccess.not()) isSuccess = false + outList.add(result.outString()) + } + + return Pair(isSuccess, outList.toLineString().trim()) + } + + suspend fun compressPackageData( logUtil: LogUtil, logId: Long, compatibleMode: Boolean, @@ -23,8 +72,6 @@ object CompressionUtil { packageName: String, dataType: DataType, ): Pair { - var isSuccess = true - val outList = mutableListOf() val excludeParaList = mutableListOf() val originPath = dataType.origin(userId) val originPathPara = "$QUOTE$originPath$QUOTE $QUOTE$packageName$QUOTE" @@ -49,24 +96,43 @@ object CompressionUtil { } else -> { - return Pair(false, outList.toLineString().trim()) + return Pair(false, "") } } - val cmd = if (compatibleMode) - "- -C $originPathPara ${if (compressionType == CompressionType.TAR) "" else "| ${compressionType.compressPara}"} > $archivePath" - else - "$archivePath -C $originPathPara ${if (compressionType == CompressionType.TAR) "" else "-I $QUOTE${compressionType.compressPara}$QUOTE"}" - // Compress data dir. - logUtil.executeWithLog(logId, "tar --totals ${excludeParaList.toSpaceString()} -cpf $cmd").also { result -> - if (result.isSuccess.not()) isSuccess = false - outList.add(result.outString()) - } + return compress( + logUtil = logUtil, + logId = logId, + compatibleMode = compatibleMode, + compressionType = compressionType, + originPathPara = originPathPara, + archivePath = archivePath, + excludeParaList = excludeParaList + ) + } - return Pair(isSuccess, outList.toLineString().trim()) + suspend fun compressMediaData( + logUtil: LogUtil, + logId: Long, + compatibleMode: Boolean, + compressionType: CompressionType, + originPath: String, + archivePath: String, + ): Pair { + val originPathPara = "$QUOTE${PathUtil.getParentPath(originPath)}$QUOTE $QUOTE${PathUtil.getFileName(originPath)}$QUOTE" + + return compress( + logUtil = logUtil, + logId = logId, + compatibleMode = compatibleMode, + compressionType = compressionType, + originPathPara = originPathPara, + archivePath = archivePath, + excludeParaList = listOf() + ) } - suspend fun compress( + suspend fun compressPackageConfig( logUtil: LogUtil, logId: Long, compatibleMode: Boolean, @@ -99,7 +165,7 @@ object CompressionUtil { return Pair(isSuccess, outList.toLineString().trim()) } - suspend fun decompress( + suspend fun decompressPackageData( logUtil: LogUtil, logId: Long, context: Context, @@ -109,8 +175,6 @@ object CompressionUtil { packageName: String, dataType: DataType, ): Pair { - var isSuccess = true - val outList = mutableListOf() val excludeParaList = mutableListOf() val cleanRestoringPara = if (context.readCleanRestoring()) "--recursive-unlink" else "" val originPath = dataType.origin(userId) @@ -131,19 +195,40 @@ object CompressionUtil { } else -> { - return Pair(false, outList.toLineString().trim()) + return Pair(false, "") } } - val cmd = "$archivePath -C $originPath ${compressionType.decompressPara}" + return decompress( + logUtil = logUtil, + logId = logId, + compressionType = compressionType, + originPath = originPath, + archivePath = archivePath, + cleanRestoringPara = cleanRestoringPara, + excludeParaList = excludeParaList + ) + } - // Decompress the archive. - logUtil.executeWithLog(logId, "tar --totals ${excludeParaList.toSpaceString()} $cleanRestoringPara -xmpf $cmd").also { result -> - if (result.isSuccess.not()) isSuccess = false - outList.add(result.outString()) - } + suspend fun decompressMediaData( + logUtil: LogUtil, + logId: Long, + context: Context, + compressionType: CompressionType, + originPath: String, + archivePath: String, + ): Pair { + val cleanRestoringPara = if (context.readCleanRestoring()) "--recursive-unlink" else "" - return Pair(isSuccess, outList.toLineString().trim()) + return decompress( + logUtil = logUtil, + logId = logId, + compressionType = compressionType, + originPath = originPath, + archivePath = archivePath, + cleanRestoringPara = cleanRestoringPara, + excludeParaList = listOf() + ) } suspend fun test( diff --git a/source/app/src/main/java/com/xayah/databackup/util/command/OperationUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/command/OperationUtil.kt index 3a5677e3d1..6159a694a0 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/command/OperationUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/command/OperationUtil.kt @@ -2,6 +2,10 @@ package com.xayah.databackup.util.command import android.content.Context import com.xayah.databackup.R +import com.xayah.databackup.data.MediaBackupOperationEntity +import com.xayah.databackup.data.MediaDao +import com.xayah.databackup.data.MediaRestoreEntity +import com.xayah.databackup.data.MediaRestoreOperationEntity import com.xayah.databackup.data.OperationState import com.xayah.databackup.data.PackageBackupOperation import com.xayah.databackup.data.PackageBackupOperationDao @@ -25,7 +29,7 @@ import com.xayah.librootservice.service.RemoteRootService fun List.toLineString() = joinToString(separator = "\n") -class OperationBackupUtil( +class PackagesBackupUtil( private val context: Context, private val timestamp: Long, private val logUtil: LogUtil, @@ -151,11 +155,12 @@ class OperationBackupUtil( } // Compress the dir. - CompressionUtil.compress(logUtil, logId, compatibleMode, userId, compressionType, archivePath, packageName, dataType).also { (succeed, out) -> - if (succeed.not()) isSuccess = false - outList.add(out) - logUtil.log(logTag, out) - } + CompressionUtil.compressPackageData(logUtil, logId, compatibleMode, userId, compressionType, archivePath, packageName, dataType) + .also { (succeed, out) -> + if (succeed.not()) isSuccess = false + outList.add(out) + logUtil.log(logTag, out) + } // Test the archive if enabled. if (context.readCompressionTest()) { @@ -188,14 +193,14 @@ class OperationBackupUtil( val archivePath = "${getPackageItemSavePath(packageName)}/${dataType.type}.${compressionType.suffix}" val outList = mutableListOf() - val tmpConfigPath = PathUtil.getTmpConfigPath(context = context, packageName = packageName, timestamp = entity.timestamp) - val tmpConfigFilePath = PathUtil.getTmpConfigFilePath(context = context, packageName = packageName, timestamp = entity.timestamp) + val tmpConfigPath = PathUtil.getTmpConfigPath(context = context, name = packageName, timestamp = entity.timestamp) + val tmpConfigFilePath = PathUtil.getTmpConfigFilePath(context = context, name = packageName, timestamp = entity.timestamp) remoteRootService.deleteRecursively(tmpConfigPath) remoteRootService.mkdirs(tmpConfigPath) remoteRootService.writeText(text = gsonUtil.toJson(entity), path = tmpConfigFilePath, context = context) // Compress the dir. - CompressionUtil.compress(logUtil, logId, compatibleMode, compressionType, archivePath, tmpConfigPath).also { (_, out) -> + CompressionUtil.compressPackageConfig(logUtil, logId, compatibleMode, compressionType, archivePath, tmpConfigPath).also { (_, out) -> outList.add(out) logUtil.log(logTag, out) } @@ -222,7 +227,7 @@ class OperationBackupUtil( } } -class OperationRestoreUtil( +class PackagesRestoreUtil( private val context: Context, private val logUtil: LogUtil, private val remoteRootService: RemoteRootService, @@ -402,7 +407,7 @@ class OperationRestoreUtil( pathContext = if (isSuccess) context else "" } // Decompress the archive. - CompressionUtil.decompress(logUtil, logId, context, userId, compressionType, archivePath, packageName, dataType).also { (succeed, out) -> + CompressionUtil.decompressPackageData(logUtil, logId, context, userId, compressionType, archivePath, packageName, dataType).also { (succeed, out) -> if (succeed.not()) isSuccess = false outList.add(out) logUtil.log(logTag, out) @@ -419,3 +424,168 @@ class OperationRestoreUtil( packageRestoreOperationDao.upsert(entity) } } + +class MediumBackupUtil( + private val context: Context, + private val timestamp: Long, + private val logUtil: LogUtil, + private val remoteRootService: RemoteRootService, + private val mediaDao: MediaDao, + private val gsonUtil: GsonUtil, +) { + // Medium use tar is enough. + private val compressionType = CompressionType.TAR + private val compatibleMode = context.readCompatibleMode() + private val mediumSavePath = PathUtil.getBackupMediumSavePath() + + fun getMediaItemSavePath(name: String): String = "${mediumSavePath}/${name}/$timestamp" + + suspend fun backupMedia(entity: MediaBackupOperationEntity) { + // Set processing state + entity.opLog = context.getString(R.string.backing_up) + entity.opState = OperationState.Processing + mediaDao.upsertBackupOp(entity) + + val logTag = "Media" + val logId = logUtil.log(logTag, "Start backing up...") + val path = entity.path + val name = entity.name + val archivePath = "${getMediaItemSavePath(name)}/${DataType.MEDIA_MEDIA.type}.${compressionType.suffix}" + var isSuccess = true + val outList = mutableListOf() + + // Check the existence of origin path. + val originPathExists = remoteRootService.exists(path) + if (originPathExists.not()) { + val msg = "${context.getString(R.string.not_exist)}: $path" + entity.opLog = msg + entity.opState = OperationState.ERROR + mediaDao.upsertBackupOp(entity) + logUtil.log(logTag, msg) + return + } + + // Compress the dir. + CompressionUtil.compressMediaData(logUtil, logId, compatibleMode, compressionType, path, archivePath) + .also { (succeed, out) -> + if (succeed.not()) isSuccess = false + outList.add(out) + logUtil.log(logTag, out) + } + + // Test the archive if enabled. + if (context.readCompressionTest()) { + CompressionUtil.test( + logUtil = logUtil, + logId = logId, + compressionType = compressionType, + archivePath = archivePath, + remoteRootService = remoteRootService + ).also { (succeed, out) -> + if (succeed.not()) { + isSuccess = false + outList.add(out) + logUtil.log(logTag, out) + } else { + logUtil.log(logTag, "$archivePath is tested well.") + } + } + } + + entity.opLog = outList.toLineString().trim() + entity.opState = if (isSuccess) OperationState.DONE else OperationState.ERROR + mediaDao.upsertBackupOp(entity) + } + + suspend fun backupConfig(entity: MediaRestoreEntity, dataType: DataType) { + val logTag = "Config" + val logId = logUtil.log(logTag, "Start backing up...") + val name = entity.name + val archivePath = "${getMediaItemSavePath(name)}/${dataType.type}.${compressionType.suffix}" + val outList = mutableListOf() + + val tmpConfigPath = PathUtil.getTmpConfigPath(context = context, name = name, timestamp = entity.timestamp) + val tmpConfigFilePath = PathUtil.getTmpConfigFilePath(context = context, name = name, timestamp = entity.timestamp) + remoteRootService.deleteRecursively(tmpConfigPath) + remoteRootService.mkdirs(tmpConfigPath) + + remoteRootService.writeText(text = gsonUtil.toJson(entity), path = tmpConfigFilePath, context = context) + // Compress the dir. + CompressionUtil.compressPackageConfig(logUtil, logId, compatibleMode, compressionType, archivePath, tmpConfigPath).also { (_, out) -> + outList.add(out) + logUtil.log(logTag, out) + } + + remoteRootService.deleteRecursively(tmpConfigPath) + + // Test the archive if enabled. + if (context.readCompressionTest()) { + CompressionUtil.test( + logUtil = logUtil, + logId = logId, + compressionType = compressionType, + archivePath = archivePath, + remoteRootService = remoteRootService + ).also { (succeed, out) -> + if (succeed.not()) { + outList.add(out) + logUtil.log(logTag, out) + } else { + logUtil.log(logTag, "$archivePath is tested well.") + } + } + } + } +} + +class MediumRestoreUtil( + private val context: Context, + private val logUtil: LogUtil, + private val remoteRootService: RemoteRootService, + private val mediaDao: MediaDao, +) { + companion object { + // Medium use tar is enough. + val compressionType = CompressionType.TAR + private val mediumSavePath = PathUtil.getRestoreMediumSavePath() + + fun getMediaItemSavePath(name: String, timestamp: Long): String = "${mediumSavePath}/${name}/$timestamp" + } + + suspend fun restoreMedia(entity: MediaRestoreOperationEntity) { + // Set processing state + entity.opLog = context.getString(R.string.restoring) + entity.opState = OperationState.Processing + mediaDao.upsertRestoreOp(entity) + + val logTag = "Media" + val logId = logUtil.log(logTag, "Start restoring...") + val path = entity.path + val archivePath = entity.archivePath + var isSuccess = true + val outList = mutableListOf() + + // Return if the archive doesn't exist. + remoteRootService.exists(archivePath).also { exists -> + if (exists.not()) { + val msg = "${context.getString(R.string.not_exist_and_skip)}: $archivePath" + entity.opLog = msg + entity.opState = OperationState.ERROR + logUtil.log(logTag, msg) + mediaDao.upsertRestoreOp(entity) + return + } + } + + // Decompress the archive. + CompressionUtil.decompressMediaData(logUtil, logId, context, compressionType, PathUtil.getParentPath(path), archivePath).also { (succeed, out) -> + if (succeed.not()) isSuccess = false + outList.add(out) + logUtil.log(logTag, out) + } + + entity.opLog = outList.toLineString().trim() + entity.opState = if (isSuccess) OperationState.DONE else OperationState.ERROR + mediaDao.upsertRestoreOp(entity) + } +} diff --git a/source/app/src/main/java/com/xayah/databackup/util/command/PreparationUtil.kt b/source/app/src/main/java/com/xayah/databackup/util/command/PreparationUtil.kt index f00b409717..8f1844c2e0 100644 --- a/source/app/src/main/java/com/xayah/databackup/util/command/PreparationUtil.kt +++ b/source/app/src/main/java/com/xayah/databackup/util/command/PreparationUtil.kt @@ -1,18 +1,24 @@ package com.xayah.databackup.util.command -import com.topjohnwu.superuser.Shell +import android.content.Context import com.xayah.databackup.util.SymbolUtil import com.xayah.databackup.util.SymbolUtil.QUOTE import com.xayah.databackup.util.command.CommonUtil.outString object PreparationUtil { suspend fun listExternalStorage(): List { - // mount | awk '$3 ~ /\mnt\/media_rw/ {print $3, $5}' + // mount | awk '$3 ~ /\mnt\/media_rw/ {print $3}' val exec = - CommonUtil.execute("mount | awk '${SymbolUtil.USD}3 ~ /${SymbolUtil.BACKSLASH}mnt${SymbolUtil.BACKSLASH}/media_rw/ {print ${SymbolUtil.USD}3, ${SymbolUtil.USD}5}'") + CommonUtil.execute("mount | awk '${SymbolUtil.USD}3 ~ /${SymbolUtil.BACKSLASH}mnt${SymbolUtil.BACKSLASH}/media_rw/ {print ${SymbolUtil.USD}3}'") return exec.out } + suspend fun getExternalStorageType(path: String): String { + // mount | awk '$3 == "/mnt/media_rw/6EBF-FE14" {print $5}' + val exec = CommonUtil.execute("mount | awk '${SymbolUtil.USD}3 == $QUOTE${path}$QUOTE {print ${SymbolUtil.USD}5}'") + return exec.out.firstOrNull() ?: "" + } + suspend fun tree(path: String): String { val exec = CommonUtil.execute("tree -N $path") return exec.outString() @@ -130,4 +136,9 @@ object PreparationUtil { val exec = CommonUtil.execute("cp -rp $path $targetPath") return Pair(exec.isSuccess, exec.outString().trim()) } + + suspend fun killDaemon(context: Context) { + // kill -9 $(ps -A | grep com.xayah.databackup.premium:root:daemon | awk 'NF>1{print $2}') + CommonUtil.execute("kill -9 ${SymbolUtil.USD}(ps -A | grep ${context.packageName}:root:daemon | awk 'NF>1{print ${SymbolUtil.USD}2}')") + } } diff --git a/source/app/src/main/res/values-tr/strings.xml b/source/app/src/main/res/values-tr/strings.xml index 28d70504b8..8e412561a3 100644 --- a/source/app/src/main/res/values-tr/strings.xml +++ b/source/app/src/main/res/values-tr/strings.xml @@ -12,7 +12,7 @@ Aktiviteler Var değil Son yedek - Yeniden yükleme, geri yükleme dizinindeki veritabanını kullanacak ve mevcut veritabanının üzerine yazacaktır (yedekleme ve geri yükleme listeleri ve günlükleri vb. dahil). Bundan sonra uygulamayı yeniden başlatmanız gerekir. Yeniden yüklemek istediğinizden emin misiniz\? + Yeniden yüklemek istediğinizden emin misiniz\? Bu uygulama tarafından oluşturulan tüm veriler yalnızca yedekleme/geri yükleme için kullanılır ve yalnızca yerel olarak depolanır. Yasadışı yeniden satış kesinlikle yasaktır Ana hafıza Üst görünüm @@ -107,7 +107,13 @@ Sıkıştırma testi Başarısız Kurtarma hedefi - Seçilen öğeleri veritabanından ve yerel dosyalardan sildiğinizden emin olun + Seçilen öğeleri veritabanından ve yerel dosyalardan sildiğinizden emin olun Sil Uygulama çöktü + Hedef seçin + Silmeye emin misin\? + Özel depolama + Desteklenen format + Belki de açacak bir tarayıcı yoktur + Ekle \ No newline at end of file diff --git a/source/app/src/main/res/values-zh-rCN/strings.xml b/source/app/src/main/res/values-zh-rCN/strings.xml index 4930200b24..2354c70fb0 100644 --- a/source/app/src/main/res/values-zh-rCN/strings.xml +++ b/source/app/src/main/res/values-zh-rCN/strings.xml @@ -74,7 +74,7 @@ 数据库重载失败 图标重载失败 重启失败, 请手动重启本应用 - 重载将使用恢复目录下的数据库并覆盖当前数据库(包括备份/恢复列表以及日志等), 重载完成后需要重启本应用, 确定要重载吗? + 确定要重载吗? 版本 特性 架构 @@ -107,7 +107,13 @@ 已安装 即将到来 删除 - 确定要从数据库以及本地文件中删除所选项吗 + 确定要从数据库以及本地文件中删除所选项吗 应用发生崩溃 也许没有浏览器能够打开它 + 支持的格式 + 添加 + 自定义存储 + 选择目标目录 + 确定要删除吗? + 无法添加备份目录作为媒体资料 \ No newline at end of file diff --git a/source/app/src/main/res/values-zh-rHK/strings.xml b/source/app/src/main/res/values-zh-rHK/strings.xml index 2fa31807a7..f96e81e6ae 100644 --- a/source/app/src/main/res/values-zh-rHK/strings.xml +++ b/source/app/src/main/res/values-zh-rHK/strings.xml @@ -74,7 +74,7 @@ 數據庫重載失敗 圖標重載失敗 重啟失敗, 請手動重啟本應用 - 重載將使用恢復目錄下的數據庫並覆蓋當前數據庫(包括備份恢復列表以及日誌等), 重載完成後需要重啟本應用, 確定要重載嗎? + 確定要重載嗎? 版本 特性 架構 @@ -107,7 +107,13 @@ 已安裝 即將到來 刪除 - 確定要從資料庫以及本機檔案中刪除所選用嗎 + 確定要從資料庫以及本機檔案中刪除所選用嗎 應用發生崩潰 也許沒有瀏覽器能夠打開它 + 支持的格式 + 添加 + 自訂存儲 + 選擇目標目錄 + 確定要刪除嗎? + 無法添加備份目錄作為媒體資料 \ No newline at end of file diff --git a/source/app/src/main/res/values-zh-rTW/strings.xml b/source/app/src/main/res/values-zh-rTW/strings.xml index 2e8594ad50..deafdcfb00 100644 --- a/source/app/src/main/res/values-zh-rTW/strings.xml +++ b/source/app/src/main/res/values-zh-rTW/strings.xml @@ -74,7 +74,7 @@ 數據庫重載失敗 圖標重載失敗 重啟失敗, 請手動重啟本應用 - 重載將使用恢復目錄下的數據庫並覆蓋當前數據庫(包括備份恢復列表以及日誌等), 重載完成後需要重啟本應用, 確定要重載嗎? + 確定要重載嗎? 版本 特性 架構 @@ -107,7 +107,13 @@ 已安裝 即將到來 刪除 - 確定要從資料庫以及本機檔案中刪除所選用嗎 + 確定要從資料庫以及本機檔案中刪除所選用嗎 應用程式產生崩潰 也許沒有瀏覽器能夠打開它 + 支持的格式 + 添加 + 自訂存儲 + 選擇目標目錄 + 確定要刪除嗎? + 無法添加備份目錄作為媒體資料 \ No newline at end of file diff --git a/source/app/src/main/res/values/strings.xml b/source/app/src/main/res/values/strings.xml index 4c41766c38..1053bb6f98 100644 --- a/source/app/src/main/res/values/strings.xml +++ b/source/app/src/main/res/values/strings.xml @@ -78,7 +78,7 @@ Failed to reload databases Failed to reload icon Failed to restart, please restart the application manually. - Reloading will use the database in the restore directory and overwrite the current database (including backup and restore lists and logs, etc.). After that, you need to restart the application. Sure to reload? + Sure to reload? Version Feature ABI @@ -112,7 +112,13 @@ Installed Sooner or later Delete - Sure to delete selected items from the database and local files + Sure to delete selected items from the database and local files App crashed Maybe there is no browser to open it + Supported format + Add + Custom storage + Select target directory + Sure to delete? + Cannot add backup directory as media \ No newline at end of file diff --git a/source/app/src/x86/assets/bin.zip b/source/app/src/x86/assets/bin.zip index 6d3c5c0509..a013c172c7 100644 Binary files a/source/app/src/x86/assets/bin.zip and b/source/app/src/x86/assets/bin.zip differ diff --git a/source/app/src/x86_64/assets/bin.zip b/source/app/src/x86_64/assets/bin.zip index 34b05f447a..4e06fcff3f 100644 Binary files a/source/app/src/x86_64/assets/bin.zip and b/source/app/src/x86_64/assets/bin.zip differ diff --git a/source/gradle.properties b/source/gradle.properties index 6dc67e2c98..afc7ef6340 100644 --- a/source/gradle.properties +++ b/source/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -XX:MaxPermSize=2048m -XX:+UseParallelGC -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects @@ -21,4 +21,7 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=false \ No newline at end of file +android.enableR8.fullMode=false + +org.gradle.daemon=true +org.gradle.parallel=true diff --git a/source/gradle/libs.versions.toml b/source/gradle/libs.versions.toml index df612fc21f..e4f0abc011 100644 --- a/source/gradle/libs.versions.toml +++ b/source/gradle/libs.versions.toml @@ -3,8 +3,10 @@ compileSdk = "34" minSdk = "28" targetSdk = "34" -versionCode = "3400100" -versionName = "1.1.0-alpha02" +# __(API)_(feature)___(version)_(abi) +# TODO: Feature bit is no longer useful, drop when next api releases +versionCode = "3411010" +versionName = "1.1.0-alpha03" compose-compiler = "1.4.3" # libraries @@ -32,9 +34,10 @@ palette = "1.0.0" google-services = "4.4.0" firebase-crashlytics-gradle = "2.9.9" firebase-bom = "32.3.1" +pickyou = "2.0.0" # plugins -android-gradle-plugin = "8.1.1" +android-gradle-plugin = "8.1.2" kotlin = "1.8.10" hilt = "2.44" ksp-kotlin = "1.8.10-1.0.9" @@ -73,6 +76,7 @@ firebase-crashlytics-gradle = { module = "com.google.firebase:firebase-crashlyti firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +pickyou = { module = "com.github.XayahSuSuSu:AndroidModule-PickYou", version.ref = "pickyou" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } diff --git a/source/librootservice/src/main/aidl/com/xayah/librootservice/IRemoteRootService.aidl b/source/librootservice/src/main/aidl/com/xayah/librootservice/IRemoteRootService.aidl index 6f52690097..7af957d855 100644 --- a/source/librootservice/src/main/aidl/com/xayah/librootservice/IRemoteRootService.aidl +++ b/source/librootservice/src/main/aidl/com/xayah/librootservice/IRemoteRootService.aidl @@ -12,6 +12,8 @@ interface IRemoteRootService { boolean deleteRecursively(String path); List listFilePaths(String path); ParcelFileDescriptor readText(String path); + long calculateSize(String path); + void clearEmptyDirectoriesRecursively(String path); ParcelFileDescriptor getInstalledPackagesAsUser(int flags, int userId); List getPackageSourceDir(String packageName, int userId); diff --git a/source/librootservice/src/main/java/com/xayah/librootservice/impl/RemoteRootServiceImpl.kt b/source/librootservice/src/main/java/com/xayah/librootservice/impl/RemoteRootServiceImpl.kt index c536c73537..f619d0381c 100644 --- a/source/librootservice/src/main/java/com/xayah/librootservice/impl/RemoteRootServiceImpl.kt +++ b/source/librootservice/src/main/java/com/xayah/librootservice/impl/RemoteRootServiceImpl.kt @@ -31,6 +31,7 @@ import java.nio.file.Path import java.nio.file.Paths import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.atomic.AtomicLong import kotlin.io.path.name import kotlin.io.path.pathString @@ -131,6 +132,61 @@ internal class RemoteRootServiceImpl : IRemoteRootService.Stub() { pfd } + override fun calculateSize(path: String): Long = synchronized(lock) { + val size = AtomicLong(0) + tryOn { + Files.walkFileTree(Paths.get(path), object : SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path?, attrs: BasicFileAttributes?): FileVisitResult { + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult { + if (file != null && attrs != null) { + size.addAndGet(attrs.size()) + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path?, exc: IOException?): FileVisitResult { + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path?, exc: IOException?): FileVisitResult { + return FileVisitResult.CONTINUE + } + }) + } + size.get() + } + + override fun clearEmptyDirectoriesRecursively(path: String) = synchronized(lock) { + tryOn { + Files.walkFileTree(Paths.get(path), object : SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path?, attrs: BasicFileAttributes?): FileVisitResult { + if (dir != null && attrs != null) { + if (Files.isDirectory(dir) && Files.list(dir).count() == 0L) { + // Empty dir + Files.delete(dir) + } + } + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult { + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path?, exc: IOException?): FileVisitResult { + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path?, exc: IOException?): FileVisitResult { + return FileVisitResult.CONTINUE + } + }) + } + } + /** * AIDL limits transaction to 1M which means it may throw [android.os.TransactionTooLargeException] * when the package list is too large. So we just make it parcelable and write into tmp file to avoid that. diff --git a/source/librootservice/src/main/java/com/xayah/librootservice/service/RemoteRootService.kt b/source/librootservice/src/main/java/com/xayah/librootservice/service/RemoteRootService.kt index 4d68d7eb38..60a7d12095 100644 --- a/source/librootservice/src/main/java/com/xayah/librootservice/service/RemoteRootService.kt +++ b/source/librootservice/src/main/java/com/xayah/librootservice/service/RemoteRootService.kt @@ -28,6 +28,12 @@ class RemoteRootService(private val context: Context) { private var mService: IRemoteRootService? = null private var mConnection: ServiceConnection? = null private var isFirstConnection = true + private val intent by lazy { + Intent().apply { + component = ComponentName(context.packageName, RemoteRootService::class.java.name) + addCategory(RootService.CATEGORY_DAEMON_MODE) + } + } class RemoteRootService : RootService() { override fun onBind(intent: Intent): IBinder = RemoteRootServiceImpl() @@ -65,10 +71,6 @@ class RemoteRootService(private val context: Context) { continuation.resumeWithException(RemoteException(msg)) } } - val intent = Intent().apply { - component = ComponentName(context.packageName, RemoteRootService::class.java.name) - addCategory(RootService.CATEGORY_DAEMON_MODE) - } RootService.bind(intent, mConnection!!) } else { mService @@ -79,9 +81,13 @@ class RemoteRootService(private val context: Context) { * Destroy the service. */ fun destroyService(killDaemon: Boolean = false) { - if (killDaemon) - if (mConnection != null) + if (killDaemon) { + if (mConnection != null) { RootService.unbind(mConnection!!) + } + RootService.stopOrTask(intent) + } + mConnection = null mService = null } @@ -150,6 +156,10 @@ class RemoteRootService(private val context: Context) { return text } + suspend fun calculateSize(path: String): Long = getService().calculateSize(path) + + suspend fun clearEmptyDirectoriesRecursively(path: String) = getService().clearEmptyDirectoriesRecursively(path) + suspend fun getInstalledPackagesAsUser(flags: Int, userId: Int): List { val pfd = getService().getInstalledPackagesAsUser(flags, userId) val stream = ParcelFileDescriptor.AutoCloseInputStream(pfd)