diff --git a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/79.json b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/79.json index 8046c77b2..2a933a415 100644 --- a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/79.json +++ b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/79.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 79, - "identityHash": "46616952f88c8e77a821de74e2d2b0ab", + "identityHash": "bc04a3804fc90f192d5fd8ac2be01644", "entities": [ { "tableName": "FloconNetworkCallEntity", @@ -1255,7 +1255,7 @@ }, { "tableName": "MockNetworkEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mockId` TEXT NOT NULL, `deviceId` TEXT, `packageName` TEXT, `isEnabled` INTEGER NOT NULL, `response` TEXT NOT NULL, `displayName` TEXT NOT NULL, `expectation_urlPattern` TEXT NOT NULL, `expectation_method` TEXT NOT NULL, PRIMARY KEY(`mockId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mockId` TEXT NOT NULL, `deviceId` TEXT, `packageName` TEXT, `isEnabled` INTEGER NOT NULL, `response` TEXT NOT NULL, `expectation_urlPattern` TEXT NOT NULL, `expectation_method` TEXT NOT NULL, PRIMARY KEY(`mockId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "mockId", @@ -1285,12 +1285,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "expectation.urlPattern", "columnName": "expectation_urlPattern", @@ -1787,11 +1781,290 @@ "id" ] } + }, + { + "tableName": "AdbSavedCommandEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `command` TEXT NOT NULL, `description` TEXT, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`deviceId`) REFERENCES `DeviceEntity`(`deviceId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbSavedCommandEntity_deviceId", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbSavedCommandEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)" + } + ], + "foreignKeys": [ + { + "table": "DeviceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "deviceId" + ], + "referencedColumns": [ + "deviceId" + ] + } + ] + }, + { + "tableName": "AdbCommandHistoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `command` TEXT NOT NULL, `output` TEXT NOT NULL, `isSuccess` INTEGER NOT NULL, `executedAt` INTEGER NOT NULL, FOREIGN KEY(`deviceId`) REFERENCES `DeviceEntity`(`deviceId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSuccess", + "columnName": "isSuccess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "executedAt", + "columnName": "executedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbCommandHistoryEntity_deviceId", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbCommandHistoryEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)" + } + ], + "foreignKeys": [ + { + "table": "DeviceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "deviceId" + ], + "referencedColumns": [ + "deviceId" + ] + } + ] + }, + { + "tableName": "AdbFlowEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`deviceId`) REFERENCES `DeviceEntity`(`deviceId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbFlowEntity_deviceId", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbFlowEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)" + } + ], + "foreignKeys": [ + { + "table": "DeviceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "deviceId" + ], + "referencedColumns": [ + "deviceId" + ] + } + ] + }, + { + "tableName": "AdbFlowStepEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `flowId` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, `command` TEXT NOT NULL, `delayAfterMs` INTEGER NOT NULL, `label` TEXT, FOREIGN KEY(`flowId`) REFERENCES `AdbFlowEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flowId", + "columnName": "flowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "delayAfterMs", + "columnName": "delayAfterMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbFlowStepEntity_flowId", + "unique": false, + "columnNames": [ + "flowId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbFlowStepEntity_flowId` ON `${TABLE_NAME}` (`flowId`)" + } + ], + "foreignKeys": [ + { + "table": "AdbFlowEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "flowId" + ], + "referencedColumns": [ + "id" + ] + } + ] } ], "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, '46616952f88c8e77a821de74e2d2b0ab')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc04a3804fc90f192d5fd8ac2be01644')" ] } } \ No newline at end of file diff --git a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/80.json b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/80.json index bc8281d25..3f6579a32 100644 --- a/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/80.json +++ b/FloconDesktop/composeApp/schemas/io.github.openflocon.flocondesktop.common.db.AppDatabase/80.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 80, - "identityHash": "894da66ff62fe4653dcff3fea6827e2e", + "identityHash": "97e741d0850a836010fcc93c41419238", "entities": [ { "tableName": "FloconNetworkCallEntity", @@ -1049,96 +1049,6 @@ } ] }, - { - "tableName": "DeeplinkVariableEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `isHistory` INTEGER NOT NULL, `mode` TEXT NOT NULL, FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "deviceId", - "columnName": "deviceId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "packageName", - "columnName": "packageName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "description", - "columnName": "description", - "affinity": "TEXT" - }, - { - "fieldPath": "isHistory", - "columnName": "isHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mode", - "columnName": "mode", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_DeeplinkVariableEntity_deviceId_packageName", - "unique": false, - "columnNames": [ - "deviceId", - "packageName" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_DeeplinkVariableEntity_deviceId_packageName` ON `${TABLE_NAME}` (`deviceId`, `packageName`)" - }, - { - "name": "index_DeeplinkVariableEntity_deviceId_name", - "unique": true, - "columnNames": [ - "deviceId", - "name" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DeeplinkVariableEntity_deviceId_name` ON `${TABLE_NAME}` (`deviceId`, `name`)" - } - ], - "foreignKeys": [ - { - "table": "DeviceAppEntity", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "deviceId", - "packageName" - ], - "referencedColumns": [ - "deviceId", - "packageName" - ] - } - ] - }, { "tableName": "AnalyticsItemEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `analyticsTableId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `appInstance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `createdAtFormatted` TEXT NOT NULL, `eventName` TEXT NOT NULL, `propertiesColumnsNames` TEXT NOT NULL, `propertiesValues` TEXT NOT NULL, PRIMARY KEY(`itemId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -1345,7 +1255,7 @@ }, { "tableName": "MockNetworkEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mockId` TEXT NOT NULL, `deviceId` TEXT, `packageName` TEXT, `isEnabled` INTEGER NOT NULL, `response` TEXT NOT NULL, `displayName` TEXT NOT NULL, `expectation_urlPattern` TEXT NOT NULL, `expectation_method` TEXT NOT NULL, PRIMARY KEY(`mockId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mockId` TEXT NOT NULL, `deviceId` TEXT, `packageName` TEXT, `isEnabled` INTEGER NOT NULL, `response` TEXT NOT NULL, `expectation_urlPattern` TEXT NOT NULL, `expectation_method` TEXT NOT NULL, PRIMARY KEY(`mockId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "mockId", @@ -1375,12 +1285,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "expectation.urlPattern", "columnName": "expectation_urlPattern", @@ -1877,11 +1781,251 @@ "id" ] } + }, + { + "tableName": "AdbSavedCommandEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `command` TEXT NOT NULL, `description` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbSavedCommandEntity_deviceId", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbSavedCommandEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)" + } + ] + }, + { + "tableName": "AdbCommandHistoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `command` TEXT NOT NULL, `output` TEXT NOT NULL, `isSuccess` INTEGER NOT NULL, `executedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSuccess", + "columnName": "isSuccess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "executedAt", + "columnName": "executedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbCommandHistoryEntity_deviceId", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbCommandHistoryEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)" + } + ] + }, + { + "tableName": "AdbFlowEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbFlowEntity_deviceId", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbFlowEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)" + } + ] + }, + { + "tableName": "AdbFlowStepEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `flowId` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, `command` TEXT NOT NULL, `delayAfterMs` INTEGER NOT NULL, `label` TEXT, FOREIGN KEY(`flowId`) REFERENCES `AdbFlowEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flowId", + "columnName": "flowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "delayAfterMs", + "columnName": "delayAfterMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AdbFlowStepEntity_flowId", + "unique": false, + "columnNames": [ + "flowId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AdbFlowStepEntity_flowId` ON `${TABLE_NAME}` (`flowId`)" + } + ], + "foreignKeys": [ + { + "table": "AdbFlowEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "flowId" + ], + "referencedColumns": [ + "id" + ] + } + ] } ], "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, '894da66ff62fe4653dcff3fea6827e2e')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '97e741d0850a836010fcc93c41419238')" ] } } \ No newline at end of file diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index ca9753685..e344f4621 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -33,6 +33,27 @@ Crashes Dashboard Database + ADB Commander + Enter ADB command (e.g. shell echo hello) + Clear All + No command history + Saved Commands + No saved commands + Automation Flows + New Flow + No automation flows + %1$d steps + Edit Flow + Flow name + Description (optional) + Steps + Add Step + ADB command + Label (optional) + Delay (ms) + Cancel Flow + Execute + Quick Commands Deeplinks Files Images diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt index 122d23f2d..0081d3b70 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt @@ -17,6 +17,7 @@ import io.github.openflocon.flocondesktop.features.analytics.analyticsRoutes import io.github.openflocon.flocondesktop.features.crashreporter.crashReporterRoutes import io.github.openflocon.flocondesktop.features.dashboard.dashboardRoutes import io.github.openflocon.flocondesktop.features.database.databaseRoutes +import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderRoutes import io.github.openflocon.flocondesktop.features.deeplinks.deeplinkRoutes import io.github.openflocon.flocondesktop.features.files.filesRoutes import io.github.openflocon.flocondesktop.features.images.imageRoutes @@ -97,6 +98,7 @@ private fun Content( dashboardRoutes() databaseRoutes() deeplinkRoutes() + adbCommanderRoutes() filesRoutes() imageRoutes() networkRoutes() diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt index b48672cf8..694988003 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt @@ -20,6 +20,7 @@ import io.github.openflocon.flocondesktop.features.analytics.AnalyticsRoutes import io.github.openflocon.flocondesktop.features.crashreporter.CrashReporterRoutes import io.github.openflocon.flocondesktop.features.dashboard.DashboardRoutes import io.github.openflocon.flocondesktop.features.database.DatabaseRoutes +import io.github.openflocon.flocondesktop.features.adbcommander.AdbCommanderRoutes import io.github.openflocon.flocondesktop.features.deeplinks.DeeplinkRoutes import io.github.openflocon.flocondesktop.features.files.FilesRoutes import io.github.openflocon.flocondesktop.features.images.ImageRoutes @@ -123,6 +124,7 @@ internal class AppViewModel( SubScreen.Dashboard -> DashboardRoutes.Main SubScreen.Database -> DatabaseRoutes.Main SubScreen.Deeplinks -> DeeplinkRoutes.Main + SubScreen.AdbCommander -> AdbCommanderRoutes.Main SubScreen.Files -> FilesRoutes.Main SubScreen.Images -> ImageRoutes.Main SubScreen.Network -> NetworkRoutes.Main diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/SubScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/SubScreen.kt index c3bbd3be6..fa8f85018 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/SubScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/SubScreen.kt @@ -18,6 +18,7 @@ sealed interface SubScreen { data object Settings : SubScreen data object Deeplinks : SubScreen + data object AdbCommander : SubScreen data object CrashReporter : SubScreen } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/leftpanel/MenuUiState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/leftpanel/MenuUiState.kt index cc6c4f7b0..359727d3f 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/leftpanel/MenuUiState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/model/leftpanel/MenuUiState.kt @@ -118,7 +118,8 @@ internal fun buildMenu() = MenuState( MenuSection( title = Res.string.menu_actions, items = listOf( - item(SubScreen.Deeplinks) + item(SubScreen.Deeplinks), + item(SubScreen.AdbCommander), ), ), ), diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/SubScreenSelectorItem.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/SubScreenSelectorItem.kt index 6da55a8b7..94ffdb1cc 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/SubScreenSelectorItem.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/SubScreenSelectorItem.kt @@ -2,6 +2,7 @@ package io.github.openflocon.flocondesktop.app.ui.view import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.filled.NetworkWifi import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.Dashboard @@ -19,6 +20,7 @@ import flocondesktop.composeapp.generated.resources.menu_item_analytics import flocondesktop.composeapp.generated.resources.menu_item_crashReporter import flocondesktop.composeapp.generated.resources.menu_item_dashboard import flocondesktop.composeapp.generated.resources.menu_item_database +import flocondesktop.composeapp.generated.resources.menu_item_adbCommander import flocondesktop.composeapp.generated.resources.menu_item_deeplinks import flocondesktop.composeapp.generated.resources.menu_item_files import flocondesktop.composeapp.generated.resources.menu_item_images @@ -43,6 +45,7 @@ fun SubScreen.displayName(): StringResource = when (this) { SubScreen.Dashboard -> Res.string.menu_item_dashboard SubScreen.Settings -> Res.string.menu_item_settings SubScreen.Deeplinks -> Res.string.menu_item_deeplinks + SubScreen.AdbCommander -> Res.string.menu_item_adbCommander SubScreen.CrashReporter -> Res.string.menu_item_crashReporter } @@ -58,5 +61,6 @@ fun SubScreen.icon(): ImageVector = when (this) { SubScreen.Settings -> Icons.Outlined.Settings SubScreen.Dashboard -> Icons.Outlined.Dashboard SubScreen.Deeplinks -> Icons.Filled.Link + SubScreen.AdbCommander -> Icons.Outlined.Terminal SubScreen.CrashReporter -> Icons.Outlined.BugReport } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/AppDatabase.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/AppDatabase.kt index 6de2f3937..2cb5a0254 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/AppDatabase.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/AppDatabase.kt @@ -5,6 +5,11 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.sqlite.driver.bundled.BundledSQLiteDriver import io.github.openflocon.data.local.adb.dao.AdbDevicesDao +import io.github.openflocon.data.local.adbcommander.dao.AdbCommanderDao +import io.github.openflocon.data.local.adbcommander.models.AdbCommandHistoryEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowStepEntity +import io.github.openflocon.data.local.adbcommander.models.AdbSavedCommandEntity import io.github.openflocon.data.local.adb.model.DeviceWithSerialEntity import io.github.openflocon.data.local.analytics.dao.FloconAnalyticsDao import io.github.openflocon.data.local.analytics.models.AnalyticsItemEntity @@ -78,7 +83,11 @@ import kotlinx.coroutines.Dispatchers DeviceAppEntity::class, DatabaseTableEntity::class, CrashReportEntity::class, - DatabaseQueryLogEntity::class + DatabaseQueryLogEntity::class, + AdbSavedCommandEntity::class, + AdbCommandHistoryEntity::class, + AdbFlowEntity::class, + AdbFlowStepEntity::class, ] ) @TypeConverters( @@ -106,6 +115,7 @@ abstract class AppDatabase : RoomDatabase() { abstract val tablesDao: TablesDao abstract val crashReportDao: CrashReportDao abstract val databaseQueryLogDao: DatabaseQueryLogDao + abstract val adbCommanderDao: AdbCommanderDao } fun getRoomDatabase(): AppDatabase = getDatabaseBuilder() diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/RoomModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/RoomModule.kt index a13d98aa7..21d61f6cf 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/RoomModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/db/RoomModule.kt @@ -61,4 +61,7 @@ val roomModule = single { get().databaseQueryLogDao } + single { + get().adbCommanderDao + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt index 58a34c48c..372722c51 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt @@ -5,6 +5,7 @@ import io.github.openflocon.flocondesktop.features.analytics.analyticsModule import io.github.openflocon.flocondesktop.features.crashreporter.crashReporterModule import io.github.openflocon.flocondesktop.features.dashboard.dashboardModule import io.github.openflocon.flocondesktop.features.database.databaseModule +import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderModule import io.github.openflocon.flocondesktop.features.deeplinks.deeplinkModule import io.github.openflocon.flocondesktop.features.files.filesModule import io.github.openflocon.flocondesktop.features.images.imagesModule @@ -26,6 +27,7 @@ val featuresModule = module { dashboardModule, tableModule, deeplinkModule, + adbCommanderModule, settingsModule, crashReporterModule, ) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/AdbCommanderViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/AdbCommanderViewModel.kt new file mode 100644 index 000000000..a44715ac5 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/AdbCommanderViewModel.kt @@ -0,0 +1,394 @@ +package io.github.openflocon.flocondesktop.features.adbcommander + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowStepDomainModel +import io.github.openflocon.domain.adbcommander.usecase.ClearCommandHistoryUseCase +import io.github.openflocon.domain.adbcommander.usecase.DeleteFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.DeleteSavedCommandUseCase +import io.github.openflocon.domain.adbcommander.usecase.ExecuteAdbCommanderCommandUseCase +import io.github.openflocon.domain.adbcommander.usecase.ExecuteFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.ObserveCommandHistoryUseCase +import io.github.openflocon.domain.adbcommander.usecase.ObserveFlowsUseCase +import io.github.openflocon.domain.adbcommander.usecase.ObserveSavedCommandsUseCase +import io.github.openflocon.domain.adbcommander.usecase.SaveCommandUseCase +import io.github.openflocon.domain.adbcommander.usecase.SaveFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.UpdateFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.UpdateSavedCommandUseCase +import io.github.openflocon.domain.common.DispatcherProvider +import io.github.openflocon.domain.feedback.FeedbackDisplayer +import io.github.openflocon.library.designsystem.common.copyToClipboard +import io.github.openflocon.flocondesktop.features.adbcommander.mapper.toUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderAction +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderUiState +import io.github.openflocon.flocondesktop.features.adbcommander.model.ConsoleOutputEntry +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowEditorState +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowEditorStepState +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AdbCommanderViewModel( + private val dispatcherProvider: DispatcherProvider, + private val feedbackDisplayer: FeedbackDisplayer, + private val executeAdbCommanderCommandUseCase: ExecuteAdbCommanderCommandUseCase, + private val observeSavedCommandsUseCase: ObserveSavedCommandsUseCase, + private val saveCommandUseCase: SaveCommandUseCase, + private val deleteSavedCommandUseCase: DeleteSavedCommandUseCase, + private val updateSavedCommandUseCase: UpdateSavedCommandUseCase, + private val observeCommandHistoryUseCase: ObserveCommandHistoryUseCase, + private val clearCommandHistoryUseCase: ClearCommandHistoryUseCase, + private val observeFlowsUseCase: ObserveFlowsUseCase, + private val saveFlowUseCase: SaveFlowUseCase, + private val deleteFlowUseCase: DeleteFlowUseCase, + private val updateFlowUseCase: UpdateFlowUseCase, + private val executeFlowUseCase: ExecuteFlowUseCase, +) : ViewModel() { + + private val localState = MutableStateFlow(AdbCommanderUiState()) + private var flowExecutionJob: Job? = null + + private val domainFlows: StateFlow> = observeFlowsUseCase() + .flowOn(dispatcherProvider.viewModel) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val uiState: StateFlow = combine( + localState, + observeSavedCommandsUseCase().mapLatest { list -> list.map { it.toUiModel() } }, + observeCommandHistoryUseCase().mapLatest { list -> list.map { it.toUiModel() } }, + domainFlows.mapLatest { list -> list.map { it.toUiModel() } }, + ) { local, savedCommands, history, flows -> + local.copy( + savedCommands = savedCommands, + history = history, + flows = flows, + ) + } + .flowOn(dispatcherProvider.viewModel) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AdbCommanderUiState(), + ) + + fun onAction(action: AdbCommanderAction) { + when (action) { + is AdbCommanderAction.CommandInputChanged -> onCommandInputChanged(action.input) + is AdbCommanderAction.ExecuteCommand -> onExecuteCommand() + is AdbCommanderAction.SaveCurrentCommand -> onSaveCurrentCommand() + is AdbCommanderAction.RunSavedCommand -> onRunSavedCommand(action.command) + is AdbCommanderAction.DeleteSavedCommand -> onDeleteSavedCommand(action.id) + is AdbCommanderAction.SaveQuickCommand -> onSaveQuickCommand(action.name, action.command) + is AdbCommanderAction.ClearHistory -> onClearHistory() + is AdbCommanderAction.RerunCommand -> onRerunCommand(action.command) + is AdbCommanderAction.ClearConsole -> onClearConsole() + is AdbCommanderAction.ShowFlowEditor -> onShowFlowEditor(action.flowId) + is AdbCommanderAction.DismissFlowEditor -> onDismissFlowEditor() + is AdbCommanderAction.FlowEditorNameChanged -> onFlowEditorNameChanged(action.name) + is AdbCommanderAction.FlowEditorDescriptionChanged -> onFlowEditorDescriptionChanged(action.description) + is AdbCommanderAction.FlowEditorStepCommandChanged -> onFlowEditorStepCommandChanged(action.index, action.command) + is AdbCommanderAction.FlowEditorStepLabelChanged -> onFlowEditorStepLabelChanged(action.index, action.label) + is AdbCommanderAction.FlowEditorStepDelayChanged -> onFlowEditorStepDelayChanged(action.index, action.delay) + is AdbCommanderAction.FlowEditorAddStep -> onFlowEditorAddStep() + is AdbCommanderAction.FlowEditorRemoveStep -> onFlowEditorRemoveStep(action.index) + is AdbCommanderAction.SaveFlow -> onSaveFlow() + is AdbCommanderAction.DeleteFlow -> onDeleteFlow(action.id) + is AdbCommanderAction.ExecuteFlow -> onExecuteFlow(action.flowId) + is AdbCommanderAction.CancelFlowExecution -> onCancelFlowExecution() + is AdbCommanderAction.CopyCommand -> onCopyCommand() + is AdbCommanderAction.ClearCommand -> onClearCommand() + } + } + + private fun onCommandInputChanged(input: String) { + localState.update { it.copy(commandInput = input) } + } + + private fun onExecuteCommand() { + val command = localState.value.commandInput.trim() + if (command.isEmpty()) return + + localState.update { it.copy(isExecuting = true) } + viewModelScope.launch(dispatcherProvider.viewModel) { + val result = executeAdbCommanderCommandUseCase(command) + result.fold( + doOnFailure = { error -> + localState.update { + it.copy( + isExecuting = false, + consoleOutput = it.consoleOutput + ConsoleOutputEntry( + command = command, + output = error.message ?: "Unknown error", + isSuccess = false, + ), + ) + } + }, + doOnSuccess = { output -> + localState.update { + it.copy( + isExecuting = false, + commandInput = "", + consoleOutput = it.consoleOutput + ConsoleOutputEntry( + command = command, + output = output.ifEmpty { "(no output)" }, + isSuccess = true, + ), + ) + } + }, + ) + } + } + + private fun onRunSavedCommand(command: String) { + localState.update { it.copy(commandInput = command) } + onExecuteCommand() + } + + private fun onSaveCurrentCommand() { + val command = localState.value.commandInput.trim() + if (command.isEmpty()) { + feedbackDisplayer.displayMessage( + "Please enter a command first", + type = FeedbackDisplayer.MessageType.Error, + ) + return + } + viewModelScope.launch(dispatcherProvider.viewModel) { + saveCommandUseCase( + AdbCommandDomainModel( + id = 0, + name = command, + command = command, + description = null, + ) + ) + feedbackDisplayer.displayMessage("Command saved") + } + } + + private fun onSaveQuickCommand(name: String, command: String) { + viewModelScope.launch(dispatcherProvider.viewModel) { + saveCommandUseCase( + AdbCommandDomainModel( + id = 0, + name = name, + command = command, + description = null, + ) + ) + feedbackDisplayer.displayMessage("Command saved to library") + } + } + + private fun onDeleteSavedCommand(id: Long) { + viewModelScope.launch(dispatcherProvider.viewModel) { + deleteSavedCommandUseCase(id) + feedbackDisplayer.displayMessage("Command deleted") + } + } + + private fun onClearHistory() { + viewModelScope.launch(dispatcherProvider.viewModel) { + clearCommandHistoryUseCase() + feedbackDisplayer.displayMessage("History cleared") + } + } + + private fun onRerunCommand(command: String) { + localState.update { it.copy(commandInput = command) } + onExecuteCommand() + } + + private fun onShowFlowEditor(flowId: Long? = null) { + if (flowId != null) { + val flow = domainFlows.value.find { it.id == flowId } + localState.update { + it.copy( + showFlowEditor = true, + flowEditorState = FlowEditorState( + flowId = flowId, + name = flow?.name ?: "", + description = flow?.description ?: "", + steps = flow?.steps?.map { step -> + FlowEditorStepState( + command = step.command, + label = step.label ?: "", + delayAfterMs = step.delayAfterMs.toString(), + ) + } ?: listOf(FlowEditorStepState()), + ), + ) + } + } else { + localState.update { + it.copy( + showFlowEditor = true, + flowEditorState = FlowEditorState(), + ) + } + } + } + + private fun onDismissFlowEditor() { + localState.update { it.copy(showFlowEditor = false) } + } + + private fun onFlowEditorNameChanged(name: String) { + localState.update { + it.copy(flowEditorState = it.flowEditorState.copy(name = name)) + } + } + + private fun onFlowEditorDescriptionChanged(description: String) { + localState.update { + it.copy(flowEditorState = it.flowEditorState.copy(description = description)) + } + } + + private fun onFlowEditorStepCommandChanged(index: Int, command: String) { + localState.update { + val steps = it.flowEditorState.steps.toMutableList() + if (index < steps.size) { + steps[index] = steps[index].copy(command = command) + } + it.copy(flowEditorState = it.flowEditorState.copy(steps = steps)) + } + } + + private fun onFlowEditorStepLabelChanged(index: Int, label: String) { + localState.update { + val steps = it.flowEditorState.steps.toMutableList() + if (index < steps.size) { + steps[index] = steps[index].copy(label = label) + } + it.copy(flowEditorState = it.flowEditorState.copy(steps = steps)) + } + } + + private fun onFlowEditorStepDelayChanged(index: Int, delay: String) { + localState.update { + val steps = it.flowEditorState.steps.toMutableList() + if (index < steps.size) { + steps[index] = steps[index].copy(delayAfterMs = delay) + } + it.copy(flowEditorState = it.flowEditorState.copy(steps = steps)) + } + } + + private fun onFlowEditorAddStep() { + localState.update { + it.copy( + flowEditorState = it.flowEditorState.copy( + steps = it.flowEditorState.steps + FlowEditorStepState() + ) + ) + } + } + + private fun onFlowEditorRemoveStep(index: Int) { + localState.update { + val steps = it.flowEditorState.steps.toMutableList() + if (steps.size > 1 && index < steps.size) { + steps.removeAt(index) + } + it.copy(flowEditorState = it.flowEditorState.copy(steps = steps)) + } + } + + private fun onSaveFlow() { + val editor = localState.value.flowEditorState + if (editor.name.isBlank()) { + feedbackDisplayer.displayMessage( + "Please enter a flow name", + type = FeedbackDisplayer.MessageType.Error, + ) + return + } + if (editor.steps.any { it.command.isBlank() }) { + feedbackDisplayer.displayMessage( + "All steps must have a command", + type = FeedbackDisplayer.MessageType.Error, + ) + return + } + + viewModelScope.launch(dispatcherProvider.viewModel) { + val flow = AdbFlowDomainModel( + id = editor.flowId ?: 0, + name = editor.name, + description = editor.description.ifBlank { null }, + steps = editor.steps.mapIndexed { index, step -> + AdbFlowStepDomainModel( + id = 0, + orderIndex = index, + command = step.command, + delayAfterMs = step.delayAfterMs.toLongOrNull() ?: 0L, + label = step.label.ifBlank { null }, + ) + }, + ) + if (editor.flowId != null) { + updateFlowUseCase(flow) + } else { + saveFlowUseCase(flow) + } + localState.update { it.copy(showFlowEditor = false) } + feedbackDisplayer.displayMessage("Flow saved") + } + } + + private fun onDeleteFlow(id: Long) { + viewModelScope.launch(dispatcherProvider.viewModel) { + deleteFlowUseCase(id) + feedbackDisplayer.displayMessage("Flow deleted") + } + } + + private fun onExecuteFlow(flowId: Long) { + val flow = domainFlows.value.find { it.id == flowId } ?: return + + flowExecutionJob?.cancel() + + flowExecutionJob = viewModelScope.launch(dispatcherProvider.viewModel) { + executeFlowUseCase(flow).collect { state -> + localState.update { it.copy(flowExecution = state.toUiModel()) } + } + } + } + + private fun onCancelFlowExecution() { + flowExecutionJob?.cancel() + flowExecutionJob = null + } + + private fun onClearConsole() { + localState.update { it.copy(consoleOutput = emptyList(), flowExecution = null) } + } + + private fun onCopyCommand() { + val command = localState.value.commandInput.trim() + if (command.isNotEmpty()) { + copyToClipboard(command) + feedbackDisplayer.displayMessage("Command copied") + } + } + + private fun onClearCommand() { + localState.update { it.copy(commandInput = "") } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/DI.kt new file mode 100644 index 000000000..9fad5d1e3 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/DI.kt @@ -0,0 +1,8 @@ +package io.github.openflocon.flocondesktop.features.adbcommander + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +internal val adbCommanderModule = module { + viewModelOf(::AdbCommanderViewModel) +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/Navigation.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/Navigation.kt new file mode 100644 index 000000000..492939f76 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/Navigation.kt @@ -0,0 +1,21 @@ +package io.github.openflocon.flocondesktop.features.adbcommander + +import androidx.navigation3.runtime.EntryProviderScope +import io.github.openflocon.flocondesktop.app.MenuSceneStrategy +import io.github.openflocon.flocondesktop.features.adbcommander.view.AdbCommanderScreen +import io.github.openflocon.navigation.FloconRoute +import kotlinx.serialization.Serializable + +sealed interface AdbCommanderRoutes : FloconRoute { + + @Serializable + data object Main : AdbCommanderRoutes +} + +fun EntryProviderScope.adbCommanderRoutes() { + entry( + metadata = MenuSceneStrategy.menu() + ) { + AdbCommanderScreen() + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/mapper/Mapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/mapper/Mapper.kt new file mode 100644 index 000000000..01c7ae1d4 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/mapper/Mapper.kt @@ -0,0 +1,60 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.mapper + +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowExecutionState +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowExecutionStepUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowExecutionUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.HistoryEntryUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.SavedCommandUiModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun AdbCommandDomainModel.toUiModel() = SavedCommandUiModel( + id = id, + name = name, + command = command, + description = description, +) + +fun AdbFlowDomainModel.toUiModel() = FlowUiModel( + id = id, + name = name, + description = description, + stepsCount = steps.size, +) + +fun AdbCommandHistoryDomainModel.toUiModel() = HistoryEntryUiModel( + id = id, + command = command, + output = output.ifEmpty { "(no output)" }, + isSuccess = isSuccess, + executedAt = formatTimestamp(executedAt), +) + +fun AdbFlowExecutionState.toUiModel() = FlowExecutionUiModel( + flowName = flowName, + steps = steps.map { stepState -> + FlowExecutionStepUiModel( + label = stepState.step.label ?: stepState.step.command, + command = stepState.step.command, + status = stepState.status.name, + output = stepState.output, + isActive = stepState.status in setOf( + AdbFlowExecutionState.StepStatus.Running, + AdbFlowExecutionState.StepStatus.WaitingDelay, + ), + ) + }, + status = status.name, + isRunning = status == AdbFlowExecutionState.FlowStatus.Running, +) + +private val timestampFormatter = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + +private fun formatTimestamp(epochMs: Long): String { + return timestampFormatter.format(Date(epochMs)) +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/AdbCommanderAction.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/AdbCommanderAction.kt new file mode 100644 index 000000000..3228afb12 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/AdbCommanderAction.kt @@ -0,0 +1,30 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.model + +sealed interface AdbCommanderAction { + data class CommandInputChanged(val input: String) : AdbCommanderAction + data object ExecuteCommand : AdbCommanderAction + data object SaveCurrentCommand : AdbCommanderAction + data class RunSavedCommand(val command: String) : AdbCommanderAction + data class DeleteSavedCommand(val id: Long) : AdbCommanderAction + data class SaveQuickCommand(val name: String, val command: String) : AdbCommanderAction + data object ClearHistory : AdbCommanderAction + data class RerunCommand(val command: String) : AdbCommanderAction + data object ClearConsole : AdbCommanderAction + data object CopyCommand : AdbCommanderAction + data object ClearCommand : AdbCommanderAction + + // Flow actions + data class ShowFlowEditor(val flowId: Long? = null) : AdbCommanderAction + data object DismissFlowEditor : AdbCommanderAction + data class FlowEditorNameChanged(val name: String) : AdbCommanderAction + data class FlowEditorDescriptionChanged(val description: String) : AdbCommanderAction + data class FlowEditorStepCommandChanged(val index: Int, val command: String) : AdbCommanderAction + data class FlowEditorStepLabelChanged(val index: Int, val label: String) : AdbCommanderAction + data class FlowEditorStepDelayChanged(val index: Int, val delay: String) : AdbCommanderAction + data object FlowEditorAddStep : AdbCommanderAction + data class FlowEditorRemoveStep(val index: Int) : AdbCommanderAction + data object SaveFlow : AdbCommanderAction + data class DeleteFlow(val id: Long) : AdbCommanderAction + data class ExecuteFlow(val flowId: Long) : AdbCommanderAction + data object CancelFlowExecution : AdbCommanderAction +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/AdbCommanderUiModels.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/AdbCommanderUiModels.kt new file mode 100644 index 000000000..017bddaa7 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/AdbCommanderUiModels.kt @@ -0,0 +1,80 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class AdbCommanderUiState( + val commandInput: String = "", + val consoleOutput: List = emptyList(), + val savedCommands: List = emptyList(), + val flows: List = emptyList(), + val history: List = emptyList(), + val flowExecution: FlowExecutionUiModel? = null, + val isExecuting: Boolean = false, + val showFlowEditor: Boolean = false, + val flowEditorState: FlowEditorState = FlowEditorState(), +) + +@Immutable +data class ConsoleOutputEntry( + val command: String, + val output: String, + val isSuccess: Boolean, +) + +@Immutable +data class SavedCommandUiModel( + val id: Long, + val name: String, + val command: String, + val description: String?, +) + +@Immutable +data class FlowUiModel( + val id: Long, + val name: String, + val description: String?, + val stepsCount: Int, +) + +@Immutable +data class HistoryEntryUiModel( + val id: Long, + val command: String, + val output: String, + val isSuccess: Boolean, + val executedAt: String, +) + +@Immutable +data class FlowExecutionUiModel( + val flowName: String, + val steps: List, + val status: String, + val isRunning: Boolean, +) + +@Immutable +data class FlowExecutionStepUiModel( + val label: String, + val command: String, + val status: String, + val output: String?, + val isActive: Boolean, +) + +@Immutable +data class FlowEditorState( + val flowId: Long? = null, + val name: String = "", + val description: String = "", + val steps: List = listOf(FlowEditorStepState()), +) + +@Immutable +data class FlowEditorStepState( + val command: String = "", + val label: String = "", + val delayAfterMs: String = "0", +) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/DefaultAdbCommands.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/DefaultAdbCommands.kt new file mode 100644 index 000000000..fc55d7467 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/model/DefaultAdbCommands.kt @@ -0,0 +1,67 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.model + +data class QuickCommand( + val name: String, + val command: String, + val category: String, +) + +val defaultQuickCommands = listOf( + // Device Info + QuickCommand("Device Model", "shell getprop ro.product.model", "Device Info"), + QuickCommand("Android Version", "shell getprop ro.build.version.release", "Device Info"), + QuickCommand("SDK Version", "shell getprop ro.build.version.sdk", "Device Info"), + QuickCommand("Device Serial", "shell getprop ro.serialno", "Device Info"), + QuickCommand("Battery Status", "shell dumpsys battery", "Device Info"), + QuickCommand("Screen Resolution", "shell wm size", "Device Info"), + QuickCommand("Screen Density", "shell wm density", "Device Info"), + QuickCommand("IP Address", "shell ip addr show wlan0", "Device Info"), + + // App Management + QuickCommand("List Installed Packages", "shell pm list packages", "App Management"), + QuickCommand("List 3rd Party Apps", "shell pm list packages -3", "App Management"), + QuickCommand("Force Stop App", "shell am force-stop [package.name]", "App Management"), + QuickCommand("Clear App Data", "shell pm clear [package.name]", "App Management"), + QuickCommand("Uninstall App", "uninstall [package.name]", "App Management"), + QuickCommand("Grant Permission", "shell pm grant [package.name] [permission]", "App Management"), + QuickCommand("Revoke Permission", "shell pm revoke [package.name] [permission]", "App Management"), + + // Input & Interaction + QuickCommand("Tap Screen", "shell input tap [x] [y]", "Input"), + QuickCommand("Swipe", "shell input swipe [x1] [y1] [x2] [y2] [duration_ms]", "Input"), + QuickCommand("Input Text", "shell input text [text]", "Input"), + QuickCommand("Press Back", "shell input keyevent 4", "Input"), + QuickCommand("Press Home", "shell input keyevent 3", "Input"), + QuickCommand("Press Enter", "shell input keyevent 66", "Input"), + QuickCommand("Press Power", "shell input keyevent 26", "Input"), + QuickCommand("Volume Up", "shell input keyevent 24", "Input"), + QuickCommand("Volume Down", "shell input keyevent 25", "Input"), + QuickCommand("Open Recent Apps", "shell input keyevent 187", "Input"), + + // Connectivity + QuickCommand("Enable WiFi", "shell svc wifi enable", "Connectivity"), + QuickCommand("Disable WiFi", "shell svc wifi disable", "Connectivity"), + QuickCommand("Enable Mobile Data", "shell svc data enable", "Connectivity"), + QuickCommand("Disable Mobile Data", "shell svc data disable", "Connectivity"), + QuickCommand("Toggle Airplane Mode ON", "shell settings put global airplane_mode_on 1", "Connectivity"), + QuickCommand("Toggle Airplane Mode OFF", "shell settings put global airplane_mode_on 0", "Connectivity"), + + // Debug & Logs + QuickCommand("Logcat (last 50 lines)", "logcat -t 50", "Debug"), + QuickCommand("Clear Logcat", "logcat -c", "Debug"), + QuickCommand("Dump Activity Stack", "shell dumpsys activity activities", "Debug"), + QuickCommand("Current Activity", "shell dumpsys activity activities | grep mResumedActivity", "Debug"), + QuickCommand("Current Fragment", "shell dumpsys activity top | grep -E 'Added Fragments|#[0-9]+:'", "Debug"), + + // Settings + QuickCommand("Open Developer Options", "shell am start -a android.settings.APPLICATION_DEVELOPMENT_SETTINGS", "Settings"), + QuickCommand("Open WiFi Settings", "shell am start -a android.settings.WIFI_SETTINGS", "Settings"), + QuickCommand("Open App Settings", "shell am start -a android.settings.APPLICATION_DETAILS_SETTINGS -d package:[package.name]", "Settings"), + QuickCommand("Open Date/Time Settings", "shell am start -a android.settings.DATE_SETTINGS", "Settings"), + + // Performance + QuickCommand("CPU Info", "shell cat /proc/cpuinfo", "Performance"), + QuickCommand("Memory Info", "shell cat /proc/meminfo", "Performance"), + QuickCommand("Running Processes", "shell ps -A", "Performance"), + QuickCommand("Disk Usage", "shell df -h", "Performance"), +) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/AdbCommanderScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/AdbCommanderScreen.kt new file mode 100644 index 000000000..de7bc68ed --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/AdbCommanderScreen.kt @@ -0,0 +1,78 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.openflocon.flocondesktop.features.adbcommander.AdbCommanderViewModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderAction +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderUiState +import io.github.openflocon.library.designsystem.components.FloconFeature +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun AdbCommanderScreen(modifier: Modifier = Modifier) { + val viewModel: AdbCommanderViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + AdbCommanderScreen( + uiState = uiState, + onAction = viewModel::onAction, + modifier = modifier, + ) +} + +@Composable +private fun AdbCommanderScreen( + uiState: AdbCommanderUiState, + onAction: (AdbCommanderAction) -> Unit, + modifier: Modifier = Modifier, +) { + FloconFeature(modifier = modifier) { + Row(Modifier.fillMaxSize()) { + CommandLibraryPanel( + savedCommands = uiState.savedCommands, + flows = uiState.flows, + onAction = onAction, + modifier = Modifier.fillMaxHeight().width(340.dp), + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + CommandInputView( + commandInput = uiState.commandInput, + history = uiState.history, + onAction = onAction, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RunnerContent( + consoleOutput = uiState.consoleOutput, + flowExecution = uiState.flowExecution, + isExecuting = uiState.isExecuting, + onAction = onAction, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + } + } + + if (uiState.showFlowEditor) { + FlowEditorDialog( + state = uiState.flowEditorState, + onAction = onAction, + ) + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/CommandInputView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/CommandInputView.kt new file mode 100644 index 000000000..bf7c35ddd --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/CommandInputView.kt @@ -0,0 +1,243 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CopyAll +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.outlined.History +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +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.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.adb_commander_execute +import flocondesktop.composeapp.generated.resources.adb_commander_input_placeholder +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderAction +import io.github.openflocon.flocondesktop.features.adbcommander.model.HistoryEntryUiModel +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconButton +import io.github.openflocon.library.designsystem.components.FloconExposedDropdownMenu +import io.github.openflocon.library.designsystem.components.FloconExposedDropdownMenuBox +import io.github.openflocon.library.designsystem.components.FloconTextField +import io.github.openflocon.library.designsystem.components.defaultPlaceHolder +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommandInputView( + commandInput: String, + history: List, + onAction: (AdbCommanderAction) -> Unit, + modifier: Modifier = Modifier, +) { + val isInputEmpty = commandInput.isBlank() + + Column( + modifier = modifier + .background( + color = FloconTheme.colorPalette.primary, + shape = FloconTheme.shapes.medium + ) + ) { + // Toolbar row + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(horizontal = 10.dp) + ) { + // Save icon + Box( + modifier = Modifier.clip(RoundedCornerShape(2.dp)) + .clickable(enabled = isInputEmpty.not()) { + onAction(AdbCommanderAction.SaveCurrentCommand) + }.aspectRatio(1f, true), + contentAlignment = Alignment.Center + ) { + Image( + Icons.Filled.StarBorder, + contentDescription = null, + modifier = Modifier.size(20.dp) + .graphicsLayer( + alpha = if (isInputEmpty) 0.6f else 1f + ), + colorFilter = ColorFilter.tint(FloconTheme.colorPalette.onPrimary) + ) + } + + VerticalDivider(modifier = Modifier.padding(vertical = 6.dp, horizontal = 2.dp)) + + // Copy icon + Box( + modifier = Modifier.clip(RoundedCornerShape(2.dp)) + .clickable(enabled = isInputEmpty.not()) { + onAction(AdbCommanderAction.CopyCommand) + }.aspectRatio(1f, true), + contentAlignment = Alignment.Center + ) { + Image( + Icons.Filled.CopyAll, + contentDescription = null, + modifier = Modifier.size(20.dp) + .graphicsLayer( + alpha = if (isInputEmpty) 0.6f else 1f + ), + colorFilter = ColorFilter.tint(FloconTheme.colorPalette.onPrimary) + ) + } + + VerticalDivider(modifier = Modifier.padding(vertical = 6.dp, horizontal = 2.dp)) + + // History dropdown + var isHistoryExpanded by remember { mutableStateOf(false) } + val historyEntries = history.take(20) + val displayHistory = isHistoryExpanded && historyEntries.isNotEmpty() + + FloconExposedDropdownMenuBox( + expanded = displayHistory, + onExpandedChange = { isHistoryExpanded = false }, + ) { + Box( + modifier = Modifier.clip(RoundedCornerShape(2.dp)) + .clickable(enabled = historyEntries.isNotEmpty()) { + isHistoryExpanded = true + }.aspectRatio(1f, true), + contentAlignment = Alignment.Center + ) { + Image( + Icons.Outlined.History, + contentDescription = null, + modifier = Modifier.size(20.dp), + colorFilter = ColorFilter.tint(FloconTheme.colorPalette.onPrimary) + ) + } + + FloconExposedDropdownMenu( + expanded = displayHistory, + onDismissRequest = { isHistoryExpanded = false }, + modifier = Modifier.width(300.dp) + ) { + historyEntries.fastForEach { entry -> + Text( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp).clickable { + onAction(AdbCommanderAction.RerunCommand(entry.command)) + isHistoryExpanded = false + }, + text = entry.command, + style = FloconTheme.typography.bodySmall, + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Clear/Delete icon + Box( + modifier = Modifier.clip(RoundedCornerShape(2.dp)).clickable { + onAction(AdbCommanderAction.ClearCommand) + }.aspectRatio(1f, true), + contentAlignment = Alignment.Center + ) { + Image( + Icons.Filled.Delete, + contentDescription = null, + modifier = Modifier.size(20.dp), + colorFilter = ColorFilter.tint(FloconTheme.colorPalette.onPrimary) + ) + } + } + + // Multi-line command input + FloconTextField( + value = commandInput, + onValueChange = { onAction(AdbCommanderAction.CommandInputChanged(it)) }, + singleLine = false, + minLines = 3, + maxLines = 6, + textStyle = FloconTheme.typography.bodyMedium, + containerColor = FloconTheme.colorPalette.secondary, + placeholder = defaultPlaceHolder(stringResource(Res.string.adb_commander_input_placeholder)), + modifier = Modifier.fillMaxWidth() + .onKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown && + keyEvent.key == androidx.compose.ui.input.key.Key.Enter && + (keyEvent.isMetaPressed || keyEvent.isCtrlPressed) + ) { + onAction(AdbCommanderAction.ExecuteCommand) + return@onKeyEvent true + } + return@onKeyEvent false + }.padding(horizontal = 6.dp, vertical = 4.dp), + ) + + // Execute button row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + FloconButton( + onClick = { + if (!isInputEmpty) + onAction(AdbCommanderAction.ExecuteCommand) + }, + containerColor = FloconTheme.colorPalette.tertiary, + modifier = Modifier + .padding(all = 8.dp) + .graphicsLayer { + if (isInputEmpty) alpha = 0.6f + } + ) { + val contentColor = FloconTheme.colorPalette.onTertiary + Row( + Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp), + colorFilter = ColorFilter.tint(contentColor) + ) + Text(stringResource(Res.string.adb_commander_execute), color = contentColor) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/CommandLibraryPanel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/CommandLibraryPanel.kt new file mode 100644 index 000000000..d99be6cae --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/CommandLibraryPanel.kt @@ -0,0 +1,223 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.view + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.adb_commander_automation_flows +import flocondesktop.composeapp.generated.resources.adb_commander_new_flow +import flocondesktop.composeapp.generated.resources.adb_commander_no_flows +import flocondesktop.composeapp.generated.resources.adb_commander_no_saved_commands +import flocondesktop.composeapp.generated.resources.adb_commander_quick_commands +import flocondesktop.composeapp.generated.resources.adb_commander_saved_commands +import flocondesktop.composeapp.generated.resources.adb_commander_steps_count +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderAction +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.QuickCommand +import io.github.openflocon.flocondesktop.features.adbcommander.model.SavedCommandUiModel +import io.github.openflocon.flocondesktop.features.adbcommander.model.defaultQuickCommands +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconButton +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconIconButton +import io.github.openflocon.library.designsystem.components.FloconSection +import org.jetbrains.compose.resources.stringResource + +@Composable +fun CommandLibraryPanel( + savedCommands: List, + flows: List, + onAction: (AdbCommanderAction) -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = FloconTheme.colorPalette.secondary + val categories = defaultQuickCommands.groupBy { it.category } + + Surface( + color = FloconTheme.colorPalette.primary, + modifier = modifier + .clip(FloconTheme.shapes.medium) + .border( + width = 1.dp, + color = borderColor, + shape = FloconTheme.shapes.medium + ) + ) { + Column( + Modifier.fillMaxSize() + ) { + // Top zone: Saved Commands + Automation Flows + Column( + Modifier.fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(all = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + FloconSection( + title = stringResource(Res.string.adb_commander_saved_commands), + initialValue = true, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (savedCommands.isEmpty()) { + Text( + text = stringResource(Res.string.adb_commander_no_saved_commands), + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f), + modifier = Modifier.padding(8.dp), + ) + } + savedCommands.forEach { command -> + CommandRow( + name = command.name, + commandText = command.command, + onClick = { onAction(AdbCommanderAction.RunSavedCommand(command.command)) }, + modifier = Modifier.fillMaxWidth(), + ) { + FloconIconButton(onClick = { onAction(AdbCommanderAction.DeleteSavedCommand(command.id)) }) { + FloconIcon(imageVector = Icons.Outlined.Delete) + } + } + } + } + } + + FloconSection( + title = stringResource(Res.string.adb_commander_automation_flows), + initialValue = true, + actions = { + FloconButton(onClick = { onAction(AdbCommanderAction.ShowFlowEditor(null)) }) { + FloconIcon(imageVector = Icons.Outlined.Add) + Text(stringResource(Res.string.adb_commander_new_flow)) + } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (flows.isEmpty()) { + Text( + text = stringResource(Res.string.adb_commander_no_flows), + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f), + modifier = Modifier.padding(8.dp), + ) + } + flows.forEach { flow -> + CommandRow( + name = flow.name, + commandText = stringResource(Res.string.adb_commander_steps_count, flow.stepsCount), + onClick = { onAction(AdbCommanderAction.ExecuteFlow(flow.id)) }, + modifier = Modifier.fillMaxWidth(), + ) { + FloconIconButton(onClick = { onAction(AdbCommanderAction.ShowFlowEditor(flow.id)) }) { + FloconIcon(imageVector = Icons.Outlined.Edit) + } + FloconIconButton(onClick = { onAction(AdbCommanderAction.DeleteFlow(flow.id)) }) { + FloconIcon(imageVector = Icons.Outlined.Delete) + } + } + } + } + } + } + + HorizontalDivider(color = borderColor) + + // Bottom zone: Quick Commands + Column( + Modifier.fillMaxWidth() + .weight(0.4f) + .verticalScroll(rememberScrollState()) + .padding(all = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + stringResource(Res.string.adb_commander_quick_commands), + color = FloconTheme.colorPalette.onSurface, + style = FloconTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + + categories.forEach { (category, commands) -> + FloconSection( + title = category, + initialValue = false, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + commands.forEach { cmd -> + CommandRow( + name = cmd.name, + commandText = cmd.command, + onClick = { onAction(AdbCommanderAction.RunSavedCommand(cmd.command)) }, + modifier = Modifier.fillMaxWidth(), + ) { + FloconIconButton(onClick = { onAction(AdbCommanderAction.SaveQuickCommand(cmd.name, cmd.command)) }) { + FloconIcon(imageVector = Icons.Outlined.Save) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun CommandRow( + name: String, + commandText: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable () -> Unit, +) { + Row( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary, + ) + Text( + text = commandText, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + actions() + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/FlowEditorDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/FlowEditorDialog.kt new file mode 100644 index 000000000..19819a972 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/FlowEditorDialog.kt @@ -0,0 +1,177 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderAction +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowEditorState +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconButton +import io.github.openflocon.library.designsystem.components.FloconDialog +import io.github.openflocon.library.designsystem.components.FloconDialogButtons +import io.github.openflocon.library.designsystem.components.FloconDialogHeader +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconIconButton +import io.github.openflocon.library.designsystem.components.FloconTextField +import io.github.openflocon.library.designsystem.components.defaultPlaceHolder +import org.jetbrains.compose.resources.stringResource +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.adb_commander_edit_flow +import flocondesktop.composeapp.generated.resources.adb_commander_new_flow +import flocondesktop.composeapp.generated.resources.adb_commander_flow_name +import flocondesktop.composeapp.generated.resources.adb_commander_flow_description +import flocondesktop.composeapp.generated.resources.adb_commander_steps +import flocondesktop.composeapp.generated.resources.adb_commander_add_step +import flocondesktop.composeapp.generated.resources.adb_commander_step_command +import flocondesktop.composeapp.generated.resources.adb_commander_step_label +import flocondesktop.composeapp.generated.resources.adb_commander_step_delay + +@Composable +fun FlowEditorDialog( + state: FlowEditorState, + onAction: (AdbCommanderAction) -> Unit, +) { + FloconDialog(onDismissRequest = { onAction(AdbCommanderAction.DismissFlowEditor) }) { + Column { + FloconDialogHeader( + title = if (state.flowId != null) { + stringResource(Res.string.adb_commander_edit_flow) + } else { + stringResource(Res.string.adb_commander_new_flow) + }, + modifier = Modifier.fillMaxWidth(), + ) + + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FloconTextField( + value = state.name, + onValueChange = { onAction(AdbCommanderAction.FlowEditorNameChanged(it)) }, + placeholder = defaultPlaceHolder(stringResource(Res.string.adb_commander_flow_name)), + modifier = Modifier.fillMaxWidth(), + ) + + FloconTextField( + value = state.description, + onValueChange = { onAction(AdbCommanderAction.FlowEditorDescriptionChanged(it)) }, + placeholder = defaultPlaceHolder(stringResource(Res.string.adb_commander_flow_description)), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.adb_commander_steps), + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onPrimary, + ) + FloconButton(onClick = { onAction(AdbCommanderAction.FlowEditorAddStep) }) { + FloconIcon(imageVector = Icons.Outlined.Add) + Text(stringResource(Res.string.adb_commander_add_step)) + } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(state.steps) { index, step -> + StepEditorItem( + index = index, + command = step.command, + label = step.label, + delayAfterMs = step.delayAfterMs, + onCommandChanged = { onAction(AdbCommanderAction.FlowEditorStepCommandChanged(index, it)) }, + onLabelChanged = { onAction(AdbCommanderAction.FlowEditorStepLabelChanged(index, it)) }, + onDelayChanged = { onAction(AdbCommanderAction.FlowEditorStepDelayChanged(index, it)) }, + onRemove = { onAction(AdbCommanderAction.FlowEditorRemoveStep(index)) }, + canRemove = state.steps.size > 1, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + FloconDialogButtons( + onCancel = { onAction(AdbCommanderAction.DismissFlowEditor) }, + onValidate = { onAction(AdbCommanderAction.SaveFlow) }, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } +} + +@Composable +private fun StepEditorItem( + index: Int, + command: String, + label: String, + delayAfterMs: String, + onCommandChanged: (String) -> Unit, + onLabelChanged: (String) -> Unit, + onDelayChanged: (String) -> Unit, + onRemove: () -> Unit, + canRemove: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "${index + 1}.", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary, + modifier = Modifier.padding(top = 8.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + FloconTextField( + value = command, + onValueChange = onCommandChanged, + placeholder = defaultPlaceHolder(stringResource(Res.string.adb_commander_step_command)), + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FloconTextField( + value = label, + onValueChange = onLabelChanged, + placeholder = defaultPlaceHolder(stringResource(Res.string.adb_commander_step_label)), + modifier = Modifier.weight(1f), + ) + FloconTextField( + value = delayAfterMs, + onValueChange = onDelayChanged, + placeholder = defaultPlaceHolder(stringResource(Res.string.adb_commander_step_delay)), + modifier = Modifier.weight(0.5f), + ) + } + } + if (canRemove) { + FloconIconButton(onClick = onRemove) { + FloconIcon(imageVector = Icons.Outlined.Delete) + } + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/RunnerContent.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/RunnerContent.kt new file mode 100644 index 000000000..2ef7074c9 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/adbcommander/view/RunnerContent.kt @@ -0,0 +1,181 @@ +package io.github.openflocon.flocondesktop.features.adbcommander.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.HourglassEmpty +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.RemoveCircle +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import io.github.openflocon.flocondesktop.features.adbcommander.model.AdbCommanderAction +import io.github.openflocon.flocondesktop.features.adbcommander.model.ConsoleOutputEntry +import io.github.openflocon.flocondesktop.features.adbcommander.model.FlowExecutionUiModel +import io.github.openflocon.library.designsystem.FloconTheme +import org.jetbrains.compose.resources.stringResource +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.adb_commander_cancel_flow +import io.github.openflocon.library.designsystem.components.FloconCircularProgressIndicator +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconIconButton +import io.github.openflocon.library.designsystem.components.FloconTextButton + +@Composable +fun RunnerContent( + consoleOutput: List, + flowExecution: FlowExecutionUiModel?, + isExecuting: Boolean, + onAction: (AdbCommanderAction) -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + LaunchedEffect(consoleOutput.size) { + if (consoleOutput.isNotEmpty()) { + listState.animateScrollToItem(consoleOutput.size - 1) + } + } + + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isExecuting) { + FloconCircularProgressIndicator() + } + if (flowExecution?.isRunning == true) { + FloconTextButton(onClick = { onAction(AdbCommanderAction.CancelFlowExecution) }) { + FloconIcon(imageVector = Icons.Outlined.Cancel) + Text(stringResource(Res.string.adb_commander_cancel_flow)) + } + } + FloconIconButton(onClick = { onAction(AdbCommanderAction.ClearConsole) }) { + FloconIcon(imageVector = Icons.Outlined.DeleteSweep) + } + } + + // Flow execution progress + if (flowExecution != null) { + FlowExecutionView( + execution = flowExecution, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) + } + + // Console output + LazyColumn( + state = listState, + contentPadding = PaddingValues(all = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + items(consoleOutput) { entry -> + ConsoleEntryView(entry = entry, modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun ConsoleEntryView( + entry: ConsoleOutputEntry, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(vertical = 2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "$ ${entry.command}", + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = FloconTheme.colorPalette.accent, + ) + Text( + text = entry.output, + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = if (entry.isSuccess) { + FloconTheme.colorPalette.onPrimary + } else { + FloconTheme.colorPalette.error + }, + ) + } +} + +@Composable +private fun FlowExecutionView( + execution: FlowExecutionUiModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Flow: ${execution.flowName} - ${execution.status}", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onPrimary, + ) + execution.steps.forEach { step -> + Row( + modifier = Modifier.fillMaxWidth().padding(start = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FloconIcon( + imageVector = step.status.toIcon(), + tint = step.status.toColor(), + modifier = Modifier.size(16.dp), + ) + Text( + text = step.label, + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = step.status.toColor(), + ) + if (step.isActive) { + FloconCircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun String.toIcon(): ImageVector = when (this) { + "Completed" -> Icons.Outlined.Check + "Failed" -> Icons.Outlined.Close + "Running" -> Icons.Outlined.PlayArrow + "WaitingDelay" -> Icons.Outlined.HourglassEmpty + "Skipped" -> Icons.Outlined.RemoveCircle + else -> Icons.Outlined.Schedule +} + +@Composable +private fun String.toColor(): Color = when (this) { + "Completed" -> FloconTheme.colorPalette.onPrimary + "Failed" -> FloconTheme.colorPalette.error + "Running", "WaitingDelay" -> FloconTheme.colorPalette.accent + else -> FloconTheme.colorPalette.onPrimary.copy(alpha = 0.5f) +} diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/DI.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/DI.kt index 860f0e4b9..997be8fe9 100644 --- a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/DI.kt +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/DI.kt @@ -1,5 +1,6 @@ package io.github.openflocon.data.core +import io.github.openflocon.data.core.adbcommander.adbCommanderModule import io.github.openflocon.data.core.analytics.analyticsModule import io.github.openflocon.data.core.crashreporter.crashReporterModule import io.github.openflocon.data.core.dashboard.dashboardModule @@ -16,6 +17,7 @@ import org.koin.dsl.module val dataCoreModule = module { includes( + adbCommanderModule, analyticsModule, dashboardModule, databaseModule, diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/DI.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/DI.kt new file mode 100644 index 000000000..471c494f5 --- /dev/null +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/DI.kt @@ -0,0 +1,13 @@ +package io.github.openflocon.data.core.adbcommander + +import io.github.openflocon.data.core.adbcommander.repository.AdbCommanderRepositoryImpl +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +internal val adbCommanderModule = module { + singleOf(::AdbCommanderRepositoryImpl) { + bind() + } +} diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/datasource/AdbCommanderLocalDataSource.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/datasource/AdbCommanderLocalDataSource.kt new file mode 100644 index 000000000..d8ae46a4c --- /dev/null +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/datasource/AdbCommanderLocalDataSource.kt @@ -0,0 +1,23 @@ +package io.github.openflocon.data.core.adbcommander.datasource + +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import kotlinx.coroutines.flow.Flow + +interface AdbCommanderLocalDataSource { + fun observeSavedCommands(deviceId: String): Flow> + suspend fun saveCommand(deviceId: String, command: AdbCommandDomainModel) + suspend fun deleteSavedCommand(id: Long) + suspend fun updateSavedCommand(command: AdbCommandDomainModel, deviceId: String) + + fun observeHistory(deviceId: String): Flow> + suspend fun addToHistory(deviceId: String, command: String, output: String, isSuccess: Boolean) + suspend fun clearHistory(deviceId: String) + + fun observeFlows(deviceId: String): Flow> + suspend fun getFlowWithSteps(flowId: Long): AdbFlowDomainModel? + suspend fun saveFlow(deviceId: String, flow: AdbFlowDomainModel): Long + suspend fun deleteFlow(id: Long) + suspend fun updateFlow(flow: AdbFlowDomainModel, deviceId: String) +} diff --git a/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/repository/AdbCommanderRepositoryImpl.kt b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/repository/AdbCommanderRepositoryImpl.kt new file mode 100644 index 000000000..cb34d80d2 --- /dev/null +++ b/FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/adbcommander/repository/AdbCommanderRepositoryImpl.kt @@ -0,0 +1,72 @@ +package io.github.openflocon.data.core.adbcommander.repository + +import io.github.openflocon.data.core.adbcommander.datasource.AdbCommanderLocalDataSource +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.common.DispatcherProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext + +class AdbCommanderRepositoryImpl( + private val localDataSource: AdbCommanderLocalDataSource, + private val dispatcherProvider: DispatcherProvider, +) : AdbCommanderRepository { + + override fun observeSavedCommands(deviceId: String): Flow> = + localDataSource.observeSavedCommands(deviceId).flowOn(dispatcherProvider.data) + + override suspend fun saveCommand(deviceId: String, command: AdbCommandDomainModel) = + withContext(dispatcherProvider.data) { + localDataSource.saveCommand(deviceId, command) + } + + override suspend fun deleteSavedCommand(id: Long) = withContext(dispatcherProvider.data) { + localDataSource.deleteSavedCommand(id) + } + + override suspend fun updateSavedCommand(deviceId: String, command: AdbCommandDomainModel) = + withContext(dispatcherProvider.data) { + localDataSource.updateSavedCommand(command, deviceId) + } + + override fun observeHistory(deviceId: String): Flow> = + localDataSource.observeHistory(deviceId).flowOn(dispatcherProvider.data) + + override suspend fun addToHistory( + deviceId: String, + command: String, + output: String, + isSuccess: Boolean, + ) = withContext(dispatcherProvider.data) { + localDataSource.addToHistory(deviceId, command, output, isSuccess) + } + + override suspend fun clearHistory(deviceId: String) = withContext(dispatcherProvider.data) { + localDataSource.clearHistory(deviceId) + } + + override fun observeFlows(deviceId: String): Flow> = + localDataSource.observeFlows(deviceId).flowOn(dispatcherProvider.data) + + override suspend fun getFlowWithSteps(flowId: Long): AdbFlowDomainModel? = + withContext(dispatcherProvider.data) { + localDataSource.getFlowWithSteps(flowId) + } + + override suspend fun saveFlow(deviceId: String, flow: AdbFlowDomainModel): Long = + withContext(dispatcherProvider.data) { + localDataSource.saveFlow(deviceId, flow) + } + + override suspend fun deleteFlow(id: Long) = withContext(dispatcherProvider.data) { + localDataSource.deleteFlow(id) + } + + override suspend fun updateFlow(deviceId: String, flow: AdbFlowDomainModel) = + withContext(dispatcherProvider.data) { + localDataSource.updateFlow(flow, deviceId) + } +} diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/DI.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/DI.kt index eb4804066..d1d229e4d 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/DI.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/DI.kt @@ -1,6 +1,7 @@ package io.github.openflocon.data.local import io.github.openflocon.data.local.adb.adbModule +import io.github.openflocon.data.local.adbcommander.adbCommanderModule import io.github.openflocon.data.local.analytics.analyticsModule import io.github.openflocon.data.local.crashreporter.crashReporterLocalModule import io.github.openflocon.data.local.dashboard.dashboardModule @@ -46,6 +47,7 @@ val dataLocalModule = module { } includes( adbModule, + adbCommanderModule, analyticsModule, dashboardModule, databaseModule, diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/DI.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/DI.kt new file mode 100644 index 000000000..1f47c20d1 --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/DI.kt @@ -0,0 +1,11 @@ +package io.github.openflocon.data.local.adbcommander + +import io.github.openflocon.data.core.adbcommander.datasource.AdbCommanderLocalDataSource +import io.github.openflocon.data.local.adbcommander.datasource.LocalAdbCommanderDataSourceRoom +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +internal val adbCommanderModule = module { + singleOf(::LocalAdbCommanderDataSourceRoom) bind AdbCommanderLocalDataSource::class +} diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/dao/AdbCommanderDao.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/dao/AdbCommanderDao.kt new file mode 100644 index 000000000..9e40e17ae --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/dao/AdbCommanderDao.kt @@ -0,0 +1,77 @@ +package io.github.openflocon.data.local.adbcommander.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.github.openflocon.data.local.adbcommander.models.AdbCommandHistoryEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowStepEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowWithSteps +import io.github.openflocon.data.local.adbcommander.models.AdbSavedCommandEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AdbCommanderDao { + + // Saved commands + @Query("SELECT * FROM AdbSavedCommandEntity WHERE deviceId = :deviceId ORDER BY createdAt DESC") + fun observeSavedCommands(deviceId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSavedCommand(command: AdbSavedCommandEntity): Long + + @Update + suspend fun updateSavedCommand(command: AdbSavedCommandEntity) + + @Query("SELECT * FROM AdbSavedCommandEntity WHERE id = :id LIMIT 1") + suspend fun getSavedCommandById(id: Long): AdbSavedCommandEntity? + + @Query("DELETE FROM AdbSavedCommandEntity WHERE id = :id") + suspend fun deleteSavedCommand(id: Long) + + // History + @Query("SELECT * FROM AdbCommandHistoryEntity WHERE deviceId = :deviceId ORDER BY executedAt DESC LIMIT 200") + fun observeHistory(deviceId: String): Flow> + + @Insert + suspend fun insertHistory(entry: AdbCommandHistoryEntity) + + @Query("DELETE FROM AdbCommandHistoryEntity WHERE deviceId = :deviceId") + suspend fun clearHistory(deviceId: String) + + // Flows + @Transaction + @Query("SELECT * FROM AdbFlowEntity WHERE deviceId = :deviceId ORDER BY createdAt DESC") + fun observeFlowsWithSteps(deviceId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertFlow(flow: AdbFlowEntity): Long + + @Update + suspend fun updateFlow(flow: AdbFlowEntity) + + @Query("DELETE FROM AdbFlowEntity WHERE id = :id") + suspend fun deleteFlow(id: Long) + + @Query("SELECT * FROM AdbFlowEntity WHERE id = :flowId LIMIT 1") + suspend fun getFlowById(flowId: Long): AdbFlowEntity? + + // Flow steps + @Query("SELECT * FROM AdbFlowStepEntity WHERE flowId = :flowId ORDER BY orderIndex ASC") + suspend fun getFlowSteps(flowId: Long): List + + @Insert + suspend fun insertFlowSteps(steps: List) + + @Query("DELETE FROM AdbFlowStepEntity WHERE flowId = :flowId") + suspend fun deleteFlowSteps(flowId: Long) + + @Transaction + suspend fun replaceFlowSteps(flowId: Long, steps: List) { + deleteFlowSteps(flowId) + insertFlowSteps(steps) + } +} diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/datasource/LocalAdbCommanderDataSourceRoom.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/datasource/LocalAdbCommanderDataSourceRoom.kt new file mode 100644 index 000000000..d349694f9 --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/datasource/LocalAdbCommanderDataSourceRoom.kt @@ -0,0 +1,114 @@ +package io.github.openflocon.data.local.adbcommander.datasource + +import io.github.openflocon.data.core.adbcommander.datasource.AdbCommanderLocalDataSource +import io.github.openflocon.data.local.adbcommander.dao.AdbCommanderDao +import io.github.openflocon.data.local.adbcommander.mapper.toDomainModel +import io.github.openflocon.data.local.adbcommander.mapper.toEntity +import io.github.openflocon.data.local.adbcommander.models.AdbCommandHistoryEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowEntity +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +internal class LocalAdbCommanderDataSourceRoom( + private val dao: AdbCommanderDao, +) : AdbCommanderLocalDataSource { + + override fun observeSavedCommands(deviceId: String): Flow> = + dao.observeSavedCommands(deviceId) + .map { entities -> entities.map { it.toDomainModel() } } + .distinctUntilChanged() + + override suspend fun saveCommand(deviceId: String, command: AdbCommandDomainModel) { + dao.insertSavedCommand(command.toEntity(deviceId)) + } + + override suspend fun deleteSavedCommand(id: Long) { + dao.deleteSavedCommand(id) + } + + override suspend fun updateSavedCommand(command: AdbCommandDomainModel, deviceId: String) { + val existing = dao.getSavedCommandById(command.id) + dao.updateSavedCommand( + command.toEntity(deviceId).copy( + createdAt = existing?.createdAt ?: System.currentTimeMillis() + ) + ) + } + + override fun observeHistory(deviceId: String): Flow> = + dao.observeHistory(deviceId) + .map { entities -> entities.map { it.toDomainModel() } } + .distinctUntilChanged() + + override suspend fun addToHistory( + deviceId: String, + command: String, + output: String, + isSuccess: Boolean, + ) { + dao.insertHistory( + AdbCommandHistoryEntity( + deviceId = deviceId, + command = command, + output = output, + isSuccess = isSuccess, + executedAt = System.currentTimeMillis(), + ) + ) + } + + override suspend fun clearHistory(deviceId: String) { + dao.clearHistory(deviceId) + } + + override fun observeFlows(deviceId: String): Flow> = + dao.observeFlowsWithSteps(deviceId) + .map { entities -> entities.map { it.toDomainModel() } } + .distinctUntilChanged() + + override suspend fun getFlowWithSteps(flowId: Long): AdbFlowDomainModel? { + val flow = dao.getFlowById(flowId) ?: return null + val steps = dao.getFlowSteps(flowId) + return flow.toDomainModel(steps) + } + + override suspend fun saveFlow(deviceId: String, flow: AdbFlowDomainModel): Long { + val flowId = dao.insertFlow( + AdbFlowEntity( + deviceId = deviceId, + name = flow.name, + description = flow.description, + createdAt = System.currentTimeMillis(), + ) + ) + dao.insertFlowSteps( + flow.steps.map { it.toEntity(flowId) } + ) + return flowId + } + + override suspend fun deleteFlow(id: Long) { + dao.deleteFlow(id) + } + + override suspend fun updateFlow(flow: AdbFlowDomainModel, deviceId: String) { + val existing = dao.getFlowById(flow.id) + dao.updateFlow( + AdbFlowEntity( + id = flow.id, + deviceId = deviceId, + name = flow.name, + description = flow.description, + createdAt = existing?.createdAt ?: System.currentTimeMillis(), + ) + ) + dao.replaceFlowSteps( + flowId = flow.id, + steps = flow.steps.map { it.toEntity(flow.id) }, + ) + } +} diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/mapper/Mapper.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/mapper/Mapper.kt new file mode 100644 index 000000000..9098f64da --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/mapper/Mapper.kt @@ -0,0 +1,66 @@ +package io.github.openflocon.data.local.adbcommander.mapper + +import io.github.openflocon.data.local.adbcommander.models.AdbCommandHistoryEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowStepEntity +import io.github.openflocon.data.local.adbcommander.models.AdbFlowWithSteps +import io.github.openflocon.data.local.adbcommander.models.AdbSavedCommandEntity +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowStepDomainModel + +fun AdbSavedCommandEntity.toDomainModel() = AdbCommandDomainModel( + id = id, + name = name, + command = command, + description = description, +) + +fun AdbCommandDomainModel.toEntity(deviceId: String) = AdbSavedCommandEntity( + id = id, + deviceId = deviceId, + name = name, + command = command, + description = description, + createdAt = System.currentTimeMillis(), +) + +fun AdbCommandHistoryEntity.toDomainModel() = AdbCommandHistoryDomainModel( + id = id, + command = command, + output = output, + isSuccess = isSuccess, + executedAt = executedAt, +) + +fun AdbFlowWithSteps.toDomainModel() = AdbFlowDomainModel( + id = flow.id, + name = flow.name, + description = flow.description, + steps = steps.sortedBy { it.orderIndex }.map { it.toDomainModel() }, +) + +fun AdbFlowEntity.toDomainModel(steps: List) = AdbFlowDomainModel( + id = id, + name = name, + description = description, + steps = steps.map { it.toDomainModel() }, +) + +fun AdbFlowStepEntity.toDomainModel() = AdbFlowStepDomainModel( + id = id, + orderIndex = orderIndex, + command = command, + delayAfterMs = delayAfterMs, + label = label, +) + +fun AdbFlowStepDomainModel.toEntity(flowId: Long) = AdbFlowStepEntity( + id = id, + flowId = flowId, + orderIndex = orderIndex, + command = command, + delayAfterMs = delayAfterMs, + label = label, +) diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbCommandHistoryEntity.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbCommandHistoryEntity.kt new file mode 100644 index 000000000..0336a946f --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbCommandHistoryEntity.kt @@ -0,0 +1,20 @@ +package io.github.openflocon.data.local.adbcommander.models + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = [ + Index(value = ["deviceId"]), + ], +) +data class AdbCommandHistoryEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val deviceId: String, + val command: String, + val output: String, + val isSuccess: Boolean, + val executedAt: Long, +) diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowEntity.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowEntity.kt new file mode 100644 index 000000000..109bdc54c --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowEntity.kt @@ -0,0 +1,19 @@ +package io.github.openflocon.data.local.adbcommander.models + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = [ + Index(value = ["deviceId"]), + ], +) +data class AdbFlowEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val deviceId: String, + val name: String, + val description: String?, + val createdAt: Long, +) diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowStepEntity.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowStepEntity.kt new file mode 100644 index 000000000..a74eff0b2 --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowStepEntity.kt @@ -0,0 +1,29 @@ +package io.github.openflocon.data.local.adbcommander.models + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = [ + Index(value = ["flowId"]), + ], + foreignKeys = [ + ForeignKey( + entity = AdbFlowEntity::class, + parentColumns = ["id"], + childColumns = ["flowId"], + onDelete = ForeignKey.CASCADE + ) + ], +) +data class AdbFlowStepEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val flowId: Long, + val orderIndex: Int, + val command: String, + val delayAfterMs: Long, + val label: String?, +) diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowWithSteps.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowWithSteps.kt new file mode 100644 index 000000000..6631bcd2c --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbFlowWithSteps.kt @@ -0,0 +1,13 @@ +package io.github.openflocon.data.local.adbcommander.models + +import androidx.room.Embedded +import androidx.room.Relation + +data class AdbFlowWithSteps( + @Embedded val flow: AdbFlowEntity, + @Relation( + parentColumn = "id", + entityColumn = "flowId", + ) + val steps: List, +) diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbSavedCommandEntity.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbSavedCommandEntity.kt new file mode 100644 index 000000000..d77692ddf --- /dev/null +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/adbcommander/models/AdbSavedCommandEntity.kt @@ -0,0 +1,20 @@ +package io.github.openflocon.data.local.adbcommander.models + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = [ + Index(value = ["deviceId"]), + ], +) +data class AdbSavedCommandEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val deviceId: String, + val name: String, + val command: String, + val description: String?, + val createdAt: Long, +) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt index 559e45eb4..410e10255 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt @@ -1,6 +1,7 @@ package io.github.openflocon.domain import io.github.openflocon.domain.adb.adbModule +import io.github.openflocon.domain.adbcommander.adbCommanderModule import io.github.openflocon.domain.analytics.analyticsModule import io.github.openflocon.domain.crashreporter.crashReporterDomainModule import io.github.openflocon.domain.dashboard.dashboardModule @@ -20,6 +21,7 @@ import org.koin.dsl.module val domainModule = module { includes( adbModule, + adbCommanderModule, analyticsModule, dashboardModule, databaseModule, diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/DI.kt new file mode 100644 index 000000000..bcffb1b0e --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/DI.kt @@ -0,0 +1,31 @@ +package io.github.openflocon.domain.adbcommander + +import io.github.openflocon.domain.adbcommander.usecase.ClearCommandHistoryUseCase +import io.github.openflocon.domain.adbcommander.usecase.DeleteFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.DeleteSavedCommandUseCase +import io.github.openflocon.domain.adbcommander.usecase.ExecuteAdbCommanderCommandUseCase +import io.github.openflocon.domain.adbcommander.usecase.ExecuteFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.ObserveCommandHistoryUseCase +import io.github.openflocon.domain.adbcommander.usecase.ObserveFlowsUseCase +import io.github.openflocon.domain.adbcommander.usecase.ObserveSavedCommandsUseCase +import io.github.openflocon.domain.adbcommander.usecase.SaveCommandUseCase +import io.github.openflocon.domain.adbcommander.usecase.SaveFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.UpdateFlowUseCase +import io.github.openflocon.domain.adbcommander.usecase.UpdateSavedCommandUseCase +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +internal val adbCommanderModule = module { + factoryOf(::ExecuteAdbCommanderCommandUseCase) + factoryOf(::ObserveSavedCommandsUseCase) + factoryOf(::SaveCommandUseCase) + factoryOf(::DeleteSavedCommandUseCase) + factoryOf(::UpdateSavedCommandUseCase) + factoryOf(::ObserveCommandHistoryUseCase) + factoryOf(::ClearCommandHistoryUseCase) + factoryOf(::ObserveFlowsUseCase) + factoryOf(::SaveFlowUseCase) + factoryOf(::DeleteFlowUseCase) + factoryOf(::UpdateFlowUseCase) + factoryOf(::ExecuteFlowUseCase) +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbCommandDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbCommandDomainModel.kt new file mode 100644 index 000000000..5b6fcfa50 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbCommandDomainModel.kt @@ -0,0 +1,8 @@ +package io.github.openflocon.domain.adbcommander.models + +data class AdbCommandDomainModel( + val id: Long, + val name: String, + val command: String, + val description: String?, +) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbCommandHistoryDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbCommandHistoryDomainModel.kt new file mode 100644 index 000000000..6eb4c784c --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbCommandHistoryDomainModel.kt @@ -0,0 +1,9 @@ +package io.github.openflocon.domain.adbcommander.models + +data class AdbCommandHistoryDomainModel( + val id: Long, + val command: String, + val output: String, + val isSuccess: Boolean, + val executedAt: Long, +) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbFlowDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbFlowDomainModel.kt new file mode 100644 index 000000000..45adff898 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbFlowDomainModel.kt @@ -0,0 +1,16 @@ +package io.github.openflocon.domain.adbcommander.models + +data class AdbFlowDomainModel( + val id: Long, + val name: String, + val description: String?, + val steps: List, +) + +data class AdbFlowStepDomainModel( + val id: Long, + val orderIndex: Int, + val command: String, + val delayAfterMs: Long, + val label: String?, +) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbFlowExecutionState.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbFlowExecutionState.kt new file mode 100644 index 000000000..ce89a0d78 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/models/AdbFlowExecutionState.kt @@ -0,0 +1,18 @@ +package io.github.openflocon.domain.adbcommander.models + +data class AdbFlowExecutionState( + val flowName: String, + val steps: List, + val currentStepIndex: Int, + val status: FlowStatus, +) { + enum class FlowStatus { Running, Completed, Cancelled, Failed } + + data class StepState( + val step: AdbFlowStepDomainModel, + val status: StepStatus, + val output: String?, + ) + + enum class StepStatus { Pending, Running, WaitingDelay, Completed, Failed, Skipped } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/repository/AdbCommanderRepository.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/repository/AdbCommanderRepository.kt new file mode 100644 index 000000000..c1d65057b --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/repository/AdbCommanderRepository.kt @@ -0,0 +1,23 @@ +package io.github.openflocon.domain.adbcommander.repository + +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import kotlinx.coroutines.flow.Flow + +interface AdbCommanderRepository { + fun observeSavedCommands(deviceId: String): Flow> + suspend fun saveCommand(deviceId: String, command: AdbCommandDomainModel) + suspend fun deleteSavedCommand(id: Long) + suspend fun updateSavedCommand(deviceId: String, command: AdbCommandDomainModel) + + fun observeHistory(deviceId: String): Flow> + suspend fun addToHistory(deviceId: String, command: String, output: String, isSuccess: Boolean) + suspend fun clearHistory(deviceId: String) + + fun observeFlows(deviceId: String): Flow> + suspend fun getFlowWithSteps(flowId: Long): AdbFlowDomainModel? + suspend fun saveFlow(deviceId: String, flow: AdbFlowDomainModel): Long + suspend fun deleteFlow(id: Long) + suspend fun updateFlow(deviceId: String, flow: AdbFlowDomainModel) +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ClearCommandHistoryUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ClearCommandHistoryUseCase.kt new file mode 100644 index 000000000..549ae3ad9 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ClearCommandHistoryUseCase.kt @@ -0,0 +1,14 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase + +class ClearCommandHistoryUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, +) { + suspend operator fun invoke() { + val deviceId = getCurrentDeviceIdUseCase() ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + adbCommanderRepository.clearHistory(deviceId) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/DeleteFlowUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/DeleteFlowUseCase.kt new file mode 100644 index 000000000..9865e4416 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/DeleteFlowUseCase.kt @@ -0,0 +1,11 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository + +class DeleteFlowUseCase( + private val adbCommanderRepository: AdbCommanderRepository, +) { + suspend operator fun invoke(id: Long) { + adbCommanderRepository.deleteFlow(id) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/DeleteSavedCommandUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/DeleteSavedCommandUseCase.kt new file mode 100644 index 000000000..ab8ce3f9c --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/DeleteSavedCommandUseCase.kt @@ -0,0 +1,11 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository + +class DeleteSavedCommandUseCase( + private val adbCommanderRepository: AdbCommanderRepository, +) { + suspend operator fun invoke(id: Long) { + adbCommanderRepository.deleteSavedCommand(id) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ExecuteAdbCommanderCommandUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ExecuteAdbCommanderCommandUseCase.kt new file mode 100644 index 000000000..3f149017f --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ExecuteAdbCommanderCommandUseCase.kt @@ -0,0 +1,57 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adb.ExecuteAdbCommandUseCase +import io.github.openflocon.domain.adb.model.AdbCommandTargetDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.common.Either +import io.github.openflocon.domain.common.Failure +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase + +class ExecuteAdbCommanderCommandUseCase( + private val executeAdbCommandUseCase: ExecuteAdbCommandUseCase, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, + private val adbCommanderRepository: AdbCommanderRepository, +) { + suspend operator fun invoke(command: String): Either { + val deviceId = getCurrentDeviceIdUseCase() + val storageDeviceId = deviceId ?: DEFAULT_DEVICE_ID + + val cleanCommand = command.trimStart().removePrefix("adb ").trimStart() + + val target = if (deviceId != null) { + AdbCommandTargetDomainModel.Device(deviceId) + } else { + AdbCommandTargetDomainModel.AllDevices + } + + val result = executeAdbCommandUseCase( + target = target, + command = cleanCommand, + ) + + result.fold( + doOnFailure = { error -> + adbCommanderRepository.addToHistory( + deviceId = storageDeviceId, + command = cleanCommand, + output = error.message ?: "Unknown error", + isSuccess = false, + ) + }, + doOnSuccess = { output -> + adbCommanderRepository.addToHistory( + deviceId = storageDeviceId, + command = cleanCommand, + output = output, + isSuccess = true, + ) + }, + ) + + return result + } + + companion object { + const val DEFAULT_DEVICE_ID = "_default" + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ExecuteFlowUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ExecuteFlowUseCase.kt new file mode 100644 index 000000000..2316aae23 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ExecuteFlowUseCase.kt @@ -0,0 +1,109 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adb.ExecuteAdbCommandUseCase +import io.github.openflocon.domain.adb.model.AdbCommandTargetDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.models.AdbFlowExecutionState +import io.github.openflocon.domain.adbcommander.models.AdbFlowExecutionState.FlowStatus +import io.github.openflocon.domain.adbcommander.models.AdbFlowExecutionState.StepStatus +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class ExecuteFlowUseCase( + private val executeAdbCommandUseCase: ExecuteAdbCommandUseCase, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, + private val adbCommanderRepository: AdbCommanderRepository, +) { + operator fun invoke(flow: AdbFlowDomainModel): Flow = flow { + val deviceId = getCurrentDeviceIdUseCase() + val storageDeviceId = deviceId ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + val target = if (deviceId != null) { + AdbCommandTargetDomainModel.Device(deviceId) + } else { + AdbCommandTargetDomainModel.AllDevices + } + + val stepStates = flow.steps.sortedBy { it.orderIndex }.map { step -> + AdbFlowExecutionState.StepState( + step = step, + status = StepStatus.Pending, + output = null, + ) + }.toMutableList() + + fun currentState(index: Int, status: FlowStatus) = AdbFlowExecutionState( + flowName = flow.name, + steps = stepStates.toList(), + currentStepIndex = index, + status = status, + ) + + emit(currentState(0, FlowStatus.Running)) + + try { + for (i in stepStates.indices) { + currentCoroutineContext().ensureActive() + + stepStates[i] = stepStates[i].copy(status = StepStatus.Running) + emit(currentState(i, FlowStatus.Running)) + + val step = stepStates[i].step + val cleanCommand = step.command.trimStart().removePrefix("adb ").trimStart() + + val result = executeAdbCommandUseCase( + target = target, + command = cleanCommand, + ) + + result.fold( + doOnFailure = { error -> + val output = error.message ?: "Unknown error" + stepStates[i] = stepStates[i].copy( + status = StepStatus.Failed, + output = output, + ) + adbCommanderRepository.addToHistory(storageDeviceId, cleanCommand, output, false) + // Mark remaining as skipped + for (j in (i + 1) until stepStates.size) { + stepStates[j] = stepStates[j].copy(status = StepStatus.Skipped) + } + emit(currentState(i, FlowStatus.Failed)) + return@flow + }, + doOnSuccess = { output -> + stepStates[i] = stepStates[i].copy( + status = StepStatus.Completed, + output = output, + ) + adbCommanderRepository.addToHistory(storageDeviceId, cleanCommand, output, true) + }, + ) + + emit(currentState(i, FlowStatus.Running)) + + if (step.delayAfterMs > 0 && i < stepStates.size - 1) { + stepStates[i] = stepStates[i].copy(status = StepStatus.WaitingDelay) + emit(currentState(i, FlowStatus.Running)) + delay(step.delayAfterMs) + stepStates[i] = stepStates[i].copy(status = StepStatus.Completed) + emit(currentState(i, FlowStatus.Running)) + } + } + + emit(currentState(stepStates.lastIndex, FlowStatus.Completed)) + } catch (_: kotlinx.coroutines.CancellationException) { + for (j in stepStates.indices) { + if (stepStates[j].status == StepStatus.Pending || stepStates[j].status == StepStatus.Running) { + stepStates[j] = stepStates[j].copy(status = StepStatus.Skipped) + } + } + emit(currentState(stepStates.lastIndex.coerceAtLeast(0), FlowStatus.Cancelled)) + throw kotlinx.coroutines.CancellationException() + } + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveCommandHistoryUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveCommandHistoryUseCase.kt new file mode 100644 index 000000000..7f73ea312 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveCommandHistoryUseCase.kt @@ -0,0 +1,19 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbCommandHistoryDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +class ObserveCommandHistoryUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase, +) { + operator fun invoke(): Flow> = + observeCurrentDeviceIdUseCase().flatMapLatest { deviceId -> + adbCommanderRepository.observeHistory( + deviceId ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + ) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveFlowsUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveFlowsUseCase.kt new file mode 100644 index 000000000..ab7cc20dc --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveFlowsUseCase.kt @@ -0,0 +1,19 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +class ObserveFlowsUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase, +) { + operator fun invoke(): Flow> = + observeCurrentDeviceIdUseCase().flatMapLatest { deviceId -> + adbCommanderRepository.observeFlows( + deviceId ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + ) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveSavedCommandsUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveSavedCommandsUseCase.kt new file mode 100644 index 000000000..25e75ac38 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/ObserveSavedCommandsUseCase.kt @@ -0,0 +1,19 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +class ObserveSavedCommandsUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase, +) { + operator fun invoke(): Flow> = + observeCurrentDeviceIdUseCase().flatMapLatest { deviceId -> + adbCommanderRepository.observeSavedCommands( + deviceId ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + ) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/SaveCommandUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/SaveCommandUseCase.kt new file mode 100644 index 000000000..ac04aa0f1 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/SaveCommandUseCase.kt @@ -0,0 +1,15 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase + +class SaveCommandUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, +) { + suspend operator fun invoke(command: AdbCommandDomainModel) { + val deviceId = getCurrentDeviceIdUseCase() ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + adbCommanderRepository.saveCommand(deviceId, command) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/SaveFlowUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/SaveFlowUseCase.kt new file mode 100644 index 000000000..49e3e3c44 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/SaveFlowUseCase.kt @@ -0,0 +1,15 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase + +class SaveFlowUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, +) { + suspend operator fun invoke(flow: AdbFlowDomainModel): Long? { + val deviceId = getCurrentDeviceIdUseCase() ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + return adbCommanderRepository.saveFlow(deviceId, flow) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/UpdateFlowUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/UpdateFlowUseCase.kt new file mode 100644 index 000000000..220021501 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/UpdateFlowUseCase.kt @@ -0,0 +1,15 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbFlowDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase + +class UpdateFlowUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, +) { + suspend operator fun invoke(flow: AdbFlowDomainModel) { + val deviceId = getCurrentDeviceIdUseCase() ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + adbCommanderRepository.updateFlow(deviceId, flow) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/UpdateSavedCommandUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/UpdateSavedCommandUseCase.kt new file mode 100644 index 000000000..b6363059f --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adbcommander/usecase/UpdateSavedCommandUseCase.kt @@ -0,0 +1,15 @@ +package io.github.openflocon.domain.adbcommander.usecase + +import io.github.openflocon.domain.adbcommander.models.AdbCommandDomainModel +import io.github.openflocon.domain.adbcommander.repository.AdbCommanderRepository +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdUseCase + +class UpdateSavedCommandUseCase( + private val adbCommanderRepository: AdbCommanderRepository, + private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase, +) { + suspend operator fun invoke(command: AdbCommandDomainModel) { + val deviceId = getCurrentDeviceIdUseCase() ?: ExecuteAdbCommanderCommandUseCase.DEFAULT_DEVICE_ID + adbCommanderRepository.updateSavedCommand(deviceId, command) + } +}