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)
+ }
+}