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)